Shipping module

Module id: shipping. Dependencies: checkout (and through it cart, catalog, customer).

What it adds:

  • Carriers — a delivery service you offer (PostNL, DHL, your own van) or a collect-in-store option.
  • Zones — where a carrier delivers: a set of countries, optionally narrowed to a set of postcodes.
  • Rates — the tariff table: price per (carrier, zone, package size, weight band), with an optional free-over threshold per row.
  • A quote endpoint the checkout picker reads, and a shipping option frozen onto each order.

Off, none of this exists and checkout charges the flat checkout_settings.shipping_flat_cents fee. Turning the module on does not change that by itself: with no carriers, the engine quotes nothing and the flat fee still applies.

How a price is decided

For a SHIP carrier the engine composes four axes:

  1. Destination → zone. A zone narrowed to postcodes wins over a country-wide zone, which wins over the rest-of-world catch-all.
  2. Weight — Σ product.weight_grams × quantity across the cart. A product with no weight counts as 0.
  3. Size class — the largest class present in the cart (letterbox / small / large), with the shop default filling in for products that declare none.
  4. Rate rows are filtered by zone + size + weight band, the surviving row's free-over threshold is applied, and the cheapest result wins.

Each SHIP carrier contributes at most one option. A PICKUP carrier skips all of it and contributes its flat pickup price (usually 0).

A pickup order is "ready for pickup", never "shipped". Mark it fulfilled when it is ready to be collected: the admin shows that step as Ready for pickup, the customer's account shows it the same way, and the customer gets a "your order is ready for pickup" email with the carrier's pickup location, in their language, without any tracking. Collection itself is not recorded; a review request counts its days from the ready date.

No zone matches → SHIP carriers offer nothing. Pickup options still show. That empty result is the mechanism behind a delivery area: see below.

Which carrier serves which zone

There is no separate carrier→zone assignment to maintain: a carrier's rate rows are its zone coverage. A zone a carrier has no price for is a zone it cannot quote, so it is simply not offered there.

That gives you both shapes for free:

  • Different carriers, different zones. Your own van has a row in Bezorggebied; DHL has rows in Europa. A Dutch customer sees the van, a German customer sees DHL, and neither sees the other.
  • Several carriers, same zone. Give PostNL and DHL each a row in Nederland and the customer gets both options at checkout, named and priced, and picks one.

Cheapest first. Two options priced the same fall back to the carrier order you set (Sort order, then name), so a tie is settled by your preference rather than at random.

The carriers list shows each carrier's zones under its rate count, and a SHIP carrier with no rates at all is flagged there — it is configured but can never be offered.

Delivery areas — postcodes

A zone's country list is the wrong unit for a business that delivers its own orders. A caterer with one van serves a handful of postcodes, not "NL". So a zone may carry postcode rules, typed one per line:

6300-6399     a range, ends included, matched on the postcode's digits
62*           a prefix — everything starting with those characters
6291          a number block: 6291 AA … 6291 ZZ
6301 CV       one exact postcode

Rules and the customer's postcode are both normalised before they meet (spaces, hyphens and dots out, upper-cased), so 6301 CV, 6301cv and 6301-CV are one address, not three.

An empty rule list means the whole country. That is what keeps every zone written before postcodes existed working, and it is why narrowing a zone is opt-in.

A range typed backwards (6399-6300) is refused by the admin rather than stored, because a rule that looks configured and matches nothing is worse than an error.

Mixed shops

Specificity, not sort order, decides. A shop with both

  • Bezorggebied — NL, postcodes 6300-6399, own van, €0
  • Nederland — NL, no postcode rules, PostNL, €6,95

quotes the van inside the area and PostNL everywhere else, whichever order the zones sit in.

Refusing an order you cannot deliver

By default an order that resolves no shipping option still goes through at the flat checkout_settings fee. That is right for a shop that posts everything — worst case the courier price is a little off.

For a business that delivers itself, it is wrong: the order is accepted and someone has to phone the customer back. Turn on Module settings → Checkout → Only accept orders we can deliver (checkout_settings.require_shipping_selection) and checkout refuses instead, naming the destination:

Helaas, wij bezorgen niet op 1012 AB.

The refusal is raised before stock is reserved, so nothing is left behind. Two distinct messages come out of it:

Situation What the customer sees
Options exist, none picked "Pick a delivery option."
No options for this address "Sorry, we do not deliver to <postcode>."

The flag only matters with the shipping module on; without it there are no quotes to require.

What a theme has to do

Checkout renders shipping_options from the address currently in the form, so a theme that renders the list server-side works with no JavaScript: fill the address, submit, and the re-render offers the options for that postcode.

Available in the checkout template context:

Variable Type What it is
shipping_options list of maps {key, carrier_code, carrier_name, label, price_cents, is_pickup, rate_id, pickup_location}
shipping_required bool Whether an order can be placed without one

Post the chosen option's key back as shipping_selection. The server re-quotes it against the address before freezing a price, so the value rendered here is advisory and a spoofed price cannot take effect.

For a live picker, refresh the same list as the customer types:

GET /checkout/shipping/quote?country=NL&postcode=6301+CV
→ {"options": [{"key": "rate:5", "carrier_name": "Bezorging", "price_cents": 0, …}]}

404 when the module is off — feature-detect on that. An empty option list is an answer ("we do not deliver there"), not a loading state, and a theme that renders it as a blank gap leaves the customer guessing.

The same endpoint answers a "do you deliver to me?" checker on the cart page. Because it is the same engine, the checker cannot promise something checkout then refuses.

Tracking

Set a tracking code on the order in the admin and the theme can link it: TrackingUrlResolver matches the frozen carrier code against PostNL, DHL, DPD, GLS, UPS and FedEx and builds the carrier's lookup URL. Anything unrecognised yields no URL and the bare code is shown, which a customer can still paste into the carrier's own site.

Worked example — a caterer who delivers in his own area

Zones:

Bezorggebied      countries: NL     postcodes: 6300-6399, 62*, 6291

No catch-all zone, because there is nowhere else he goes.

Carriers:

Bezorging   kind: SHIP    (one rate: zone Bezorggebied, any size, any weight, €0)

Checkout settings:

Only accept orders we can deliver   [✓]

Result: 6301 CV is offered free delivery; 1012 AB is told, on the cart page and again at checkout, that the shop does not come there, and the order cannot be placed.

To also post outside the area, add a second zone (Nederland, no postcode rules) and give a courier a rate row in it. The van still wins inside 6300-6399 — a postcode zone beats a country-wide one — and the courier picks up everywhere else. Give both a row in the same zone instead and the customer chooses between them.

Overridable templates

Shipping ships no templates of its own — the picker lives in the checkout template:

@GsidCmsCore/checkout/index.html.twig  →  @website/checkout/index.html.twig