Appearance
Technical reference
For developers
Every table, service, listener, event, route, template, configuration key and identifier the plugin defines. For how they work together, read Architecture & data flow first.
Data model
All entities are registered through the DAL; repositories follow Shopware's naming (kmh_pickup_location.repository, …).
kmh_pickup_location — PickupLocationDefinition
| Field (property) | Type | Notes |
|---|---|---|
id | id | |
name | string, required | Shown in checkout and emails. |
street | string, required | |
additional_address_line1, additional_address_line2 | string | Stored; not rendered in the storefront, emails or order tab. |
city, postal_code | string, required | |
email | string, required | Recipient of Send pickup notification to admin. |
phone_number | string | Used by the SMS action; digits only are sent. |
timezone | string | IANA name. Unset or invalid → UTC. |
time_format | string | Default 24h. No field in the Administration and not read anywhere. |
latitude, longitude | string | Used for nearest-first ordering; blank or non-numeric values sort last. |
location_code | string | Merchant reference; shown on the order tab. |
active | bool | DB default 0. |
default_sales_channel_id (defaultSalesChannelId) | FK → sales_channel, required | ON DELETE SET NULL. The location locked in when customer selection is disabled — see Configuration. |
Associations: salesChannels (ManyToMany via kmh_pickup_location_sales_channel), defaultSalesChannel (ManyToOne), openingHoursSchedule and specialHours (OneToMany, cascade delete).
Schedule tables
| Table | Fields | Notes |
|---|---|---|
kmh_pickup_location_opening_hour | pickup_location_id, day_of_week (ISO 1 = Monday … 7), open_time, close_time (HH:MM) | Several rows per day = several intervals. A day without rows is closed. |
kmh_pickup_location_special_hour | pickup_location_id, date, closed, open_time, close_time | Overrides the weekly schedule for that date. |
kmh_pickup_location_sales_channel | pickup_location_id, sales_channel_id | ManyToMany mapping. |
kmh_order_pickup_location — OrderPickupLocationDefinition
One row per pickup order.
| Field | Notes |
|---|---|
id + version_id | Primary key. VersionField: the Administration's order drafts get their own copy. |
order_id + order_version_id | FK to order (versioned), ON DELETE CASCADE; unique together. |
pickup_location_id | Nullable, ON DELETE SET NULL — deleting a location keeps the record's time and comment. Association pickupLocation is autoloaded. |
pickup_time | DATETIME(3), nullable; stored in UTC. Format it with the location's timezone. |
comment | Long text, nullable; the storefront limits input to 500 characters. |
OrderExtension adds kmhPickupLocation to order as an autoloaded OneToOne with cascade delete (so version clones include it):
twig
{% set pickup = order.extensions.kmhPickupLocation %}
{{ pickup.pickupTime|format_datetime('medium', 'short', timezone=pickup.pickupLocation.timezone|default('UTC')) }}Services
| Class | Responsibility |
|---|---|
PickupLocation\Availability\PickupLocationAvailabilityService | Stateless; works on loaded schedule associations. isOpenAt() (special date wins; intervals half-open [open, close); overnight intervals wrap), isOpenOnDate(), getOpenIntervalsForDate(), filterOpen(), filterOpenOnDate(), filterOpenWithinDays(locations, days, ?ref) (open on at least one of the next days local dates, today included). |
PickupLocation\Availability\PickupTimeSlotService | getSlots(location, date, ?now) — steps each open interval by slotStepMinutes (default DEFAULT_STEP_MINUTES = 30), keeps the whole slot inside the interval, drops past and disallowed slots. isBookable(location, when) = isOpenAt() && isAllowed(). protected isAllowed() returns true — the extension point. |
PickupLocation\PickupLocationSelectionResolver | isPickupShippingMethod(), resolve() (location), resolveSelection() (location + parsed time + comment). Reads the persisted store; only active locations assigned to the current sales channel resolve. |
PickupLocation\DefaultPickupLocationProvider | isSelectionEnabled(salesChannelId) (enablePickupLocationSelection), getDefaultLocationId(context) — active, assigned to the channel, defaultSalesChannelId = channel, first by name. |
PickupLocation\PickupLocationValidator | EntityExists check (active, assigned to the sales channel) for a context switch; throws ConstraintViolationException. |
Checkout\PickupSelection\PickupContextStorage | load(), save(), clear() over SalesChannelContextPersister. Scoped to the customer only when genuinely logged in (not during admin impersonation). |
Checkout\PickupSelection\OrderPickupLocationWriter | persist(orderId, selection, context) → creates the order record and returns the entity. |
Checkout\Cart\PayOnPickupCartProcessor | Cart validator (tag shopware.cart.validator); see errors. |
Checkout\Payment\PayOnPickupPaymentHandler | Extends Shopware's DefaultPayment — no online charge; the payment stays Open. |
Installer\PaymentMethodInstaller, Installer\ShippingMethodInstaller | Idempotent create/update, (de)activate and assignToSalesChannels(). |
Twig\FormattedCalendarDays | Twig function format_calendar_days(days) — localised weekday names; a continuous range is collapsed to Monday - Friday, otherwise a comma list. Registered, but no plugin template uses it. |
Listeners
| Class | Event | Does |
|---|---|---|
Listener\SwitchContextEventListener | SwitchContextEvent::CONSISTENT_CHECK | If the request has pickupLocationId: empty → clear; otherwise validate and store id, pickupTime, pickupComment. With selection disabled the id is replaced by the default location. |
SalesChannelContextResolvedEvent | With the pickup shipping method: if nothing is stored and selection is disabled, store the default location; then attach the stored values as context extension pickupLocation (keys id, pickupLocationId, pickupTime, pickupComment). | |
Listener\OrderListener | CheckoutOrderPlacedEvent | Write the order record, dispatch pickup.order.placed, clear the selection. |
Listener\PickupOrderReadyListener | state_machine.order_delivery.state_changed | On enter ready: dispatch pickup.order.ready if the order has a record with a location. |
Listener\BusinessEventCollectorListener | BusinessEventCollectorEvent (priority 1000) | Registers both events as Flow Builder triggers. |
Storefront\Cookie\PickupLocationGeolocationCookieListener | CookieGroupCollectEvent | Adds the optional cookie group kmh_pickup_location_geolocation (value 1, 30 days). |
Flow Builder
Triggers
| Event | Class | When | Data |
|---|---|---|---|
pickup.order.placed | Event\PickupOrderPlacedEvent | After a pickup order is placed | order, pickupLocation, pickupOrderLocation |
pickup.order.ready | Event\PickupOrderReadyEvent | A pickup order's delivery enters ready | order, pickupLocation, pickupOrderLocation |
Both implement OrderAware, MailAware, CustomerAware, SalesChannelAware, PickupLocationAware and OrderPickupLocationAware; placed also implements SalesChannelContextAware and CustomerGroupAware. getMailStruct() addresses the order customer and returns no recipients when the customer has no email. In the trigger picker they appear as Pickup / Order / Placed and Pickup / Order / Ready; in the flow list as Pickup order placed / Pickup order ready.
Flow\Storer\PickupLocationFlowStorer and Flow\Storer\OrderPickupLocationFlowStorer store only the ids (pickupLocationId, pickupOrderLocationId) and lazy-load the entities, so delayed flows stay small.
Actions
| Key | Class | Requires | Does |
|---|---|---|---|
action.kmh.pickup.notify_admin | Flow\Action\SendPickupNotificationToAdminAction | OrderAware, PickupLocationAware | Emails the location's email (skipped and logged if invalid). Template: action config mailTemplateId, else the seeded admin template. Sender: core.basicInformation.email of the order's sales channel. Template data: order, customer, pickupLocation, pickupOrderLocation. |
action.kmh.pickup.notify_sms | Flow\Action\SendSmsToPickupLocationAction | OrderAware, PickupLocationAware | Texts the location's phone (non-digits stripped): Pickup order <number> for <location>. Pickup time: Y-m-d H:i. — in the location's time zone (UTC if unset or invalid). No-op without the SMS plugin or when its gateway is not configured for the sales channel. |
Both are delayable and log-and-swallow send errors. The Administration adds them to a Click & Pick action group; the mail action opens sw-flow-pickup-notify-modal to pick a template.
The SMS gateway is injected as @?Kommandhub\SmsSW\Notification\Gateway\NotificationGatewayInterface and used through the local, duck-typed Flow\Sms\SmsGateway interface (isConfigured(), send()). The SMS plugin is not a Composer requirement.
Installed flows
| Flow | Trigger | Action |
|---|---|---|
| Send pickup notification to admin | pickup.order.placed | action.kmh.pickup.notify_admin |
| Send pickup ready mail to customer | pickup.order.ready | Shopware's send mail with the Order ready for pickup template |
Mail templates
| Type | Subject (en-GB) | Variables used |
|---|---|---|
| Order ready for pickup | Order ready for pickup | order, pickupLocation, pickupOrderLocation |
| New Pickup Order Placed - Admin Notification | New pickup order received - #{{ order.orderNumber }} | order, pickupOrderLocation (+ pickupLocation from the action) |
Templates exist in en-GB and de-DE. Times are formatted with timezone=pickupLocation.timezone (or pickupOrderLocation.pickupLocation.timezone).
Storefront
Routes — SalesChannelPickupLocationController
| Name | Path | Returns |
|---|---|---|
frontend.kmh.sales-channel.pickup-locations.index | GET /kmh/sales-channel/{salesChannelId}/pickup-locations | <option> list: active, assigned to the channel, open on at least one day of the next BOOKING_WINDOW_DAYS (14) in each location's zone. With selection disabled: only locations whose defaultSalesChannelId is the channel, preselected, without the placeholder. Sorted by name (natural, case-insensitive). With lat and lon (numeric, ±90/±180) and enableGeolocationSorting on: nearest first by Haversine distance (R = 6371 km), locations without coordinates last, ties by name. |
frontend.kmh.sales-channel.pickup-locations.slots | GET /kmh/sales-channel/{salesChannelId}/location/{locationId}/slots?date=YYYY-MM-DD | <option> list of bookable slots. The date is built at midnight in the location's zone; unknown location or bad date → empty list. |
Both are XHR routes in the storefront scope. The context switch itself uses Shopware's POST /checkout/configure.
Templates and blocks
| Template | Extends / used by | Blocks |
|---|---|---|
component/shipping/shipping-form.html.twig | Extends core, block component_shipping_form_method | Includes the plugin's custom/shipping-method.html.twig for every shipping method |
component/shipping/custom/shipping-method.html.twig | Extends core component/shipping/shipping-method.html.twig, block component_shipping_method_label | component_shipping_method_label_kmh_self_pickup(_inner), …_field_container, …_field_label, …_location_field, …_geolocation, …_time, …_time_date, …_time_select, …_time_comment |
component/shipping/custom/pickup-location-select-option.html.twig | Location route | pickup_location_select_option_container |
component/shipping/custom/pickup-time-select-option.html.twig | Slots route | pickup_time_select_option_container |
component/shipping/custom/pickup-location-field-info.html.twig | Finish page (the schedule section stays empty there: the order's location is loaded without its schedule) | pickup_location_field_info, …_card, …_name, …_address_section, …_schedule_section, …_selected_time_section, …_contact_section |
page/checkout/finish/finish-address.html.twig | Extends core, block page_checkout_finish_address_shipping_data | component_shipping_method_label_kmh_self_pickup_location_field_info — renders the card only while the record still has a location |
With enablePickupLocationSelection off, the dropdown renders disabled with a hidden pickupLocationId input, and the geolocation block is skipped.
JavaScript plugins
| Name | Selector | Options |
|---|---|---|
SalesChannelPickupLocation | [data-sales-channel-pickup-location="true"] | url, selectedPickupLocationId, geolocationEnabled, geolocationCookieName (kmh_pickup_location_geolocation). Loads options, posts the selection to /checkout/configure and reloads. Shows Use my location only with browser geolocation support and the consent cookie; requests the position (timeout 10 s, max age 5 min) and re-fetches with rounded coordinates. |
SalesChannelPickupTime | [data-sales-channel-pickup-time="true"] | url, selectedPickupTime. Date helper defaults to the stored date or the customer's local today (min = today) and loads slots on change. |
Assets are built to src/Resources/app/storefront/dist/storefront/js/kmh-click-and-pick-s-w/.
Context and request keys
PickupContextKeys: context extension pickupLocation; payload/request keys pickupLocationId, pickupTime (ISO-8601 with offset, e.g. 2026-10-03T11:00:00+01:00), pickupComment.
Cart errors
| Class | Message key (checkout. / error. prefix) |
|---|---|
PickupLocationRequiredCartBlockerError | kmh-click-and-pick.pickupLocationRequired |
PickupTimeRequiredCartBlockerError | kmh-click-and-pick.pickupTimeRequired |
InvalidPickupTimeCartBlockerError | kmh-click-and-pick.invalidPickupTime |
UnsupportedDeliveryMethodCartBlockerError | kmh-click-and-pick.unsupportedDeliveryMethod (parameter deliveryMethodName) |
Storefront snippets live under general.kmh-click-and-pick.* in en-GB, de-DE and fr-FR. The location field uses general.kmh-click-and-pick.pickupLocationField.label / .placeholder. Administration snippets add sw-flow-custom-event.flow-list.pickup_order_placed|ready and …event-tree.pickup|ready so the triggers are labelled in Flow Builder.
Administration
| Part | Details |
|---|---|
Module kmh-pickup-location | Under Content (sw-content, position 3), routes kmh.pickup.location.index / .create / .edit. Components kmh-pickup-location-base-form and kmh-pickup-location-schedule. Registered as a search type, so locations are found from the Administration's global search (name, street, email, city, phone…). |
| ACL | Key kmh_pickup_location under Content: viewer (also needs sales_channel.viewer), editor, creator, deleter. |
| Order tab | Module kmh-order-pickup-info injects child route sw.order.detail.pickup (/sw/order/detail/:id/pickup, privilege order.viewer) into sw.order.detail via routeMiddleware; the tab item is added in block sw_order_detail_content_tabs_extension. Component kmh-order-pickup-info-tab loads the record by orderId and formats the pickup time in the location's zone (other dates in the user's zone). |
| Flow Builder | Override of sw-flow-sequence-action (titles, icons, Click & Pick group, descriptions) and modal sw-flow-pickup-notify-modal. |
Built assets: src/Resources/public/administration/ (entry kmh-click-and-pick-s-w).
Configuration
config.xml, read as KmhClickAndPickSW.config.<key>, all per sales channel.
| Key | Default | Read by | Effect |
|---|---|---|---|
enablePickupLocationSelection | true | Shipping-method template, location route, DefaultPickupLocationProvider | On: customers choose. Off: the location whose defaultSalesChannelId is the channel is pre-selected, stored and locked; Use my location hidden. |
showStreetNameInPickupLocationSelectionField | false | Location option template | Adds - <street> to each option (the city is always shown). |
showContactDetailInPickupLocationInfo | true | Info card (finish page) | Phone and email rows. |
enableGeolocationSorting | true | Shipping-method template, location route | Use my location button and coordinate-based ordering. |
Defaults are written on install and update (CONFIG_DEFAULTS) only for keys without a stored value.
Identifiers
| What | Value |
|---|---|
| Shipping method | id 25b9d4e415428362abb32d0a7cba2a38 (KmhClickAndPickSW::SHIPPING_METHOD_ID), technical name kmh_self_pickup, delivery time 15-30 minutes, rule All customers, price 0 |
| Payment method | technical name kmh_pay_on_pickup, afterOrderEnabled, rule Payment method is self pick-up (ShippingMethodRule = self pick-up) |
| Delivery state | ready (id KmhClickAndPickSW::STATE_READY_FOR_PICKUP_ID); transitions ready (open → ready), reopen (ready → open), ship (ready → shipped), cancel (ready → cancelled) |
| Cookie group | kmh_pickup_location_geolocation |
| Context extension | pickupLocation |
| Order extension | kmhPickupLocation |