Appearance
Architecture & data flow
For developers
How Click and Pick is built and how a pickup order moves through it. Class-by-class details, routes, events and configuration keys are in the Technical reference; local setup and tests in Development & testing.
| Plugin class | Kommandhub\ClickAndPickSW\KmhClickAndPickSW (technical name KmhClickAndPickSW) |
| Namespace | Kommandhub\ClickAndPickSW\ → src/ (PSR-4) |
| Package | kommandhub/click-and-pick-sw |
| Requires | shopware/core and shopware/storefront ~6.7.0, PHP 8.2+ |
| Optional | SMS plugin for the SMS flow action |
Scope
Implemented:
- Pickup locations (Administration module) with address, contact, coordinates, a default sales channel, sales-channel assignment, an IANA time zone, weekly opening intervals and date overrides.
- A Self pick-up shipping method (
kmh_self_pickup) and a Pay on pickup payment method (kmh_pay_on_pickup), assigned to every sales channel. - Selection of location, date/time slot and a comment in checkout, persisted in the sales-channel context and validated in the cart.
- Optional, consent-gated nearest-first ordering of the location list.
- One pickup record per order (
kmh_order_pickup_location), exposed as an order extension. - A Ready order-delivery state, two mail templates, two Flow Builder triggers and two Flow Builder actions (mail, optional SMS).
- A read-only Pickup information tab on the order.
Not implemented: slot capacity, lead time, cut-off and blackout rules (there is an extension point), delivery routing, POS integration.
Module layout
Feature-first directories under src/; inside each, flat Symfony-style folders. Cross-module access goes through services.
text
src/
Checkout/
Cart/ PayOnPickupCartProcessor (cart validator) + Error/ (3 blockers)
Payment/ PayOnPickupPaymentHandler
PickupSelection/ PickupContextStorage, PickupContextKeys, StoredPickupSelection,
PickupSelection, OrderPickupLocationWriter
PickupLocation/
Availability/ PickupLocationAvailabilityService, PickupTimeSlotService
PickupLocationSelectionResolver, PickupLocationValidator,
DefaultPickupLocationProvider
Entity/ PickupLocation (+ Aggregate/: OpeningHour, SpecialHour,
SalesChannelMapping), OrderPickupLocation, Order/Aggregated/…
Extension/ OrderExtension (order → pickup record)
Event/ PickupOrderPlacedEvent, PickupOrderReadyEvent
Flow/ Action/ (2), Aware/ (2), Storer/ (2), Sms/SmsGateway
Listener/ SwitchContextEventListener, OrderListener,
PickupOrderReadyListener, BusinessEventCollectorListener
Storefront/ Controller/SalesChannelPickupLocationController,
Cookie/PickupLocationGeolocationCookieListener
Installer/ PaymentMethodInstaller, ShippingMethodInstaller
Migration/ 10 migrations
Twig/ FormattedCalendarDays (format_calendar_days function)
Resources/ config/ (services.yml, routes.xml, config.xml),
app/administration/, app/storefront/, views/, snippet/,
public/ (built Administration assets — generated)Design decisions
- Data in DAL entities, not custom fields. Locations, schedules and the per-order pickup record are dedicated entities. The order record is a OneToOne extension (
order.extensions.kmhPickupLocation), autoloaded so every order read carries it. It has its ownVersionField, like Shopware's order children: when the Administration clones an order into a draft version, the record is copied with it, and discarding or merging the draft never touches the live row. - The in-progress selection is context state, not cart state. It lives in the
sales_channel_api_contextpayload through Shopware'sSalesChannelContextPersister, the same store as the chosen shipping and payment method. It therefore follows the customer through login and stays isolated per sales channel. The cart validator always reads this persisted store, never a request-local value. - Time-zone-correct availability in PHP. Opening times are local wall-clock strings; every open/closed decision runs in the location's own IANA zone. A single SQL weekday filter cannot do this, because the local weekday differs per zone.
- Autowiring.
services.ymlautowires and autoconfigures../../*. Entity definitions, extensions, the cart validator, flow storers and the attribute-based listeners register themselves. Only the two flow actions and the storefront controllers are configured explicitly.
Data flow: from checkout to collection
1. Choosing a location
- The shipping-method template renders, under Self pick-up, a location
<select name="pickupLocationId">, a "Use my location" button (if enabled), and — once a location is stored — a date helper, a<select name="pickupTime">and a<textarea name="pickupComment">. - The
SalesChannelPickupLocationJS plugin fetches the location<option>s fromGET /kmh/sales-channel/{salesChannelId}/pickup-locations— locations open on at least one day of the nextBOOKING_WINDOW_DAYS(14). With consent, Use my location re-fetches withlat/lon, rounded to two decimals, for nearest-first ordering. - Choosing a location sends
POST /checkout/configurewith{"pickupLocationId": …}and reloads the page. SwitchContextEventListener(onSwitchContextEvent::CONSISTENT_CHECK) validates the id withPickupLocationValidator— active and assigned to the current sales channel, otherwise aConstraintViolationExceptionaborts the switch. It then savespickupLocationId,pickupTimeandpickupCommentfrom the same request throughPickupContextStorage. An emptypickupLocationIdclears the selection; requests without the key leave it untouched.
With enablePickupLocationSelection off, DefaultPickupLocationProvider supplies the location instead: the active location assigned to the channel whose defaultSalesChannelId is that channel (first by name). On context resolve the listener stores it if nothing is stored; on a switch it replaces whatever was posted. The template renders the dropdown disabled plus a hidden pickupLocationId, so choosing the time still submits the location, and hides Use my location.
2. Choosing a time
- The
SalesChannelPickupTimeJS plugin sets the date input to the stored date or the customer's local today (minimum today) and loads slot<option>s fromGET /kmh/sales-channel/{salesChannelId}/location/{locationId}/slots?date=YYYY-MM-DD. - The date input has no
nameand stops itschangeevent, so it never submits the form. Choosing a time or editing the comment does: the fields carry Shopware'sshipping-method-inputclass, so the core shipping form auto-submits the context switch, and step 1.4 stores all three values again. - On every resolved context with the pickup shipping method,
SwitchContextEventListenerre-attaches the stored values as thepickupLocationcontext extension, which the template reads to preselect them.
3. Validating the cart
PayOnPickupCartProcessor (a shopware.cart.validator) runs on every cart calculation:
| Condition | Error (blockOrder() = true) |
|---|---|
| Payment is Pay on pickup and shipping is not Self pick-up | UnsupportedDeliveryMethodCartBlockerError |
| Shipping is Self pick-up and no stored location resolves (missing, inactive, not assigned to the channel) | PickupLocationRequiredCartBlockerError |
| Shipping is Self pick-up, a location resolves but no time is stored | PickupTimeRequiredCartBlockerError |
A time is stored and PickupTimeSlotService::isBookable() rejects it | InvalidPickupTimeCartBlockerError |
4. Placing the order
OrderListener on CheckoutOrderPlacedEvent, for orders with the pickup shipping method:
- resolves the stored selection (
PickupLocationSelectionResolver::resolveSelection()); - writes the
kmh_order_pickup_locationrow (OrderPickupLocationWriter); - dispatches
PickupOrderPlacedEvent(pickup.order.placed) — the installed flow emails the location; - clears the context selection, so the next cart in the session starts empty.
5. Marking it ready
PickupOrderReadyListener listens to state_machine.order_delivery.state_changed. When a delivery enters the ready state, it loads the order (with orderCustomer and kmhPickupLocation.pickupLocation) and dispatches PickupOrderReadyEvent (pickup.order.ready) — the installed flow emails the customer. Orders without a pickup record, or whose location was deleted, are skipped.
Lifecycle
| Hook | What happens |
|---|---|
install | Payment and shipping methods created (idempotent, fixed ids) and linked to every sales channel; missing configuration keys seeded with their defaults. |
update | Same as install, so methods and defaults are repaired. Migrations run. |
activate / deactivate | Payment and shipping methods (de)activated; on activate they are linked to every sales channel again. |
uninstall | Methods are deactivated, never deleted (orders reference them). Without keep user data, the five plugin tables are dropped child-first. Mail templates, flows and the Ready state are kept. |
Seeding writes only keys with no stored value (KmhClickAndPickSW::CONFIG_DEFAULTS), so a merchant's saved choice survives updates. Default values in config.xml alone are not returned by SystemConfigService until saved — which is why the seeding exists.
Migrations
| Migration | Creates / changes |
|---|---|
1759696668PickupLocation | kmh_pickup_location (incl. default_sales_channel_id, index on active) and kmh_pickup_location_sales_channel |
1760109246AddReadyForPickupOrderState | ready state in order_delivery.state + transitions ready, reopen, ship, cancel |
1760113852PickupReadyMailTemplate | Mail template types and templates (en-GB, de-DE) |
1760115677AddPickupMailSendFlow | Flow Send pickup ready mail to customer |
1760200000AddPickupAdminNotificationFlow | Flow Send pickup notification to admin |
1760300000NormalizedPickupSchedule | timezone column, kmh_pickup_location_opening_hour, kmh_pickup_location_special_hour |
1760400000OrderPickupLocation | kmh_order_pickup_location |
1760500000ResyncPickupMailsAndFlow | Re-syncs both mail templates and points the ready flow at pickup.order.ready (for existing installs) |
1760600000AddDefaultSalesChannelColumn | Adds default_sales_channel_id where an earlier install lacks it, backfilled from each location's sales channels |
1760600001VersionOrderPickupLocation | Makes kmh_order_pickup_location versioned (version_id, PK (id, version_id)) and moves records stranded on an Administration draft back to the live order |
Performance and caching
- The selection is a single indexed lookup in the context payload; nothing is added to the cart.
- The context-resolved listener returns immediately unless the pickup shipping method is selected.
- The location list filters by
salesChannels.idwithout hydrating the association, and loads the schedule associations batched (IN (…)), so there is no N+1. - Availability is evaluated in PHP per location and is not cached across time zones; working sets are a merchant's locations.
- The location list response varies with
lat/lonand must stay uncacheable (no HTTP-cache attribute). Coordinates are used for that response only — not stored, logged or returned. - Indexes:
kmh_pickup_location.activeand.location_code; mapping PK(pickup_location_id, sales_channel_id);(pickup_location_id, day_of_week)and(pickup_location_id, date)on the schedule tables; unique(order_id, order_version_id)and an index onpickup_location_idon the order record.