kommandhub / click-and-pick-sw
Click and collect for Shopware 6: pickup-location selection in checkout, a self-pickup shipping method and a pay-on-pickup payment method, with pickup-ready order state and mail automation.
Package info
github.com/KommandHub/KommandhubClickAndPickSW
Type:shopware-platform-plugin
pkg:composer/kommandhub/click-and-pick-sw
Requires
- shopware/core: ~6.7.0
- shopware/storefront: ~6.7.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.88
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
- rregeer/phpunit-coverage-check: ^0.3.1
- shopware/dev-tools: ^1.5
This package is auto-updated.
Last update: 2026-08-21 14:22:58 UTC
README
Click and Pick for Shopware 6 — Technical Documentation
KommandhubClickAndPickSW adds click-and-collect to Shopware 6.7: a self-pickup
shipping method, a pay-on-pickup payment method, pickup-location management with a
timezone-aware opening schedule, in-checkout selection of a pickup location + date/
time, server-side validation, a pickup-ready order-delivery state, and Flow Builder
triggers/actions for mail and (optional) SMS notifications.
This document targets developers who maintain, test, or extend the plugin. It is not a merchant/end-user guide.
- Namespace:
Kommandhub\ClickAndPickSW\→src/(PSR-4) - Plugin class:
Kommandhub\ClickAndPickSW\KommandhubClickAndPickSW - Shopware:
~6.7.0(shopware/core,shopware/storefront) - License: Proprietary (see
LICENSE)
1. Scope
Implemented, and only this:
- Pickup-location entity + admin module (address/contact/geo, sales-channel assignment, per-location IANA timezone, weekly opening intervals, special-date overrides).
kommandhub_self_pickupshipping method andkommandhub_pay_on_pickuppayment method (payment restricted to the pickup shipping context).- In-checkout pickup-location + pickup date/time + free-text comment selection, persisted in the sales-channel context and validated in the cart.
- Order pickup record entity (
kommandhub_order_pickup_location) as the single source of truth for an order's pickup data; a OneToOne order extension. - a pickup-ready order-delivery state (technical name
ready, added to theorder_delivery.statemachine); mail templates;pickup.order.placedandpickup.order.readyFlow Builder triggers; admin-mail and optional-SMS actions. - Read-only "Pickup information" tab on the admin order detail page.
Out of scope: capacity/slot-quota booking, cut-off/lead-time rules, blackout periods (there is an extension seam for these — see §12), delivery routing, POS.
2. Architecture & design decisions
- Feature-first modules under
src/(mirrors Shopware's own plugin layout). A top-level directory is a boundary; inside it, flat Symfony-idiomatic folders (Service-like classes,Listener,Handler,Struct,Event). One obvious home per class; cross-module access goes through a service, not deep-linking. - DAL-first data model. Pickup data lives in dedicated DAL entities, never in order custom fields. The order's pickup record is a versioned OneToOne extension.
- Selection is context state, not cart state. The in-progress selection is
stored in the
sales_channel_api_contextpayload via Shopware'sSalesChannelContextPersister— the same store as the selected shipping/payment method — so it inherits login-migration, customer scoping, and per-sales-channel isolation for free. The cart validator reads the persisted store (authoritative), never a request-local extension, so a valid selection cannot intermittently disappear between cart recalculations. - Timezone-correct availability. All open/closed decisions are evaluated in the location's own IANA timezone in PHP; opening times are local wall-clock strings.
- Autowiring via the
../../*glob (services.yml);autoconfigure: trueauto-tags entity definitions, extensions, cart validators, and flow storers. Only Flow actions and the storefront controllers need explicit service config.
3. Project structure
src/
Checkout/
Cart/ PayOnPickupCartProcessor (cart validator) + Error/ blockers
Payment/ PayOnPickupPaymentHandler
PickupSelection/ PickupContextStorage/Keys, StoredPickupSelection (raw),
PickupSelection (resolved), OrderPickupLocationWriter
Entity/
PickupLocation/ definition/entity/collection + Aggregate/ (OpeningHour,
SpecialHour, SalesChannelMapping)
OrderPickupLocation/ order pickup record entity
Order/Aggregated/… OrderDeliveryStates, transition-action constants
Extension/OrderExtension OneToOne order → order pickup record
Event/ PickupOrderPlacedEvent, PickupOrderReadyEvent
Flow/
Aware/ PickupLocationAware, OrderPickupLocationAware
Storer/ flow storers for the two aware contracts
Action/ SendPickupNotificationToAdminAction, SendSmsToPickupLocationAction
Sms/SmsGateway duck-typed interface for the optional SMS plugin
Listener/ SwitchContextEventListener, OrderListener,
PickupOrderReadyListener, BusinessEventCollectorListener
PickupLocation/
Availability/ PickupLocationAvailabilityService, PickupTimeSlotService
PickupLocationSelectionResolver, PickupLocationValidator
Installer/ PaymentMethodInstaller, ShippingMethodInstaller
Migration/ 7 migrations (see §14)
Storefront/Controller/ SalesChannelPickupLocationController
Twig/FormattedCalendarDays weekday-name formatting helper
Resources/
config/ services.yml, routes.xml, config.xml
app/administration/ Vue admin (pickup-location module + order tab)
app/storefront/ storefront JS plugins (src + built dist)
views/ storefront Twig overrides
snippet/ storefront snippets (en-GB, de-DE, fr-FR)
public/ built administration assets (generated)
tests/
Unit/ mirrors src/; no kernel
Integration/ kernel-backed (@group kernel)
4. Data model & DAL
| Table | Entity | Notes |
|---|---|---|
kommandhub_pickup_location |
PickupLocationDefinition |
address/contact/geo, time_format, timezone, active, location_code. Index on active. |
kommandhub_pickup_location_sales_channel |
…SalesChannelMappingDefinition |
M2M mapping. PK (pickup_location_id, sales_channel_id); FK to sales_channel auto-indexes the reverse direction. |
kommandhub_pickup_location_opening_hour |
…OpeningHourDefinition |
weekly intervals: day_of_week (ISO 1–7), open_time/close_time (HH:MM). Index (pickup_location_id, day_of_week). |
kommandhub_pickup_location_special_hour |
…SpecialHourDefinition |
date overrides: date, closed, optional open_time/close_time. Index (pickup_location_id, date). |
kommandhub_order_pickup_location |
OrderPickupLocationDefinition |
one row per pickup order: order_id+order_version_id, nullable pickup_location_id, pickup_time, comment. |
Associations
PickupLocation.salesChannels— M2M tosales_channelvia the mapping table.PickupLocation.openingHoursSchedule/.specialHours— OneToMany,CascadeDelete.OrderExtensionaddsorder.kommandhubPickupLocation— OneToOne, autoloaded,CascadeDelete. Every order read (finish page, account, admin API, Flow order data) carries the pickup record without a criteria subscriber.OrderPickupLocation.pickupLocation— ManyToOne, autoloaded.
Referential integrity
- Order pickup record →
orderFK is(order_id, order_version_id) → order(id, version_id)ON DELETE CASCADE(versioned). Unique on(order_id, order_version_id). - Order pickup record → pickup location FK is
ON DELETE SET NULL: deleting a location preserves the order's historical pickup data (time/comment) and nulls the reference. The admin tab renders a "location deleted" state for this case.
5. Cart & checkout lifecycle
Selection is a two-stage value model:
StoredPickupSelection— raw strings read from the context payload.PickupSelection— resolved: a loadedPickupLocationEntity(+ its schedule associations) plus a parsed\DateTimeImmutabletime and comment.
Persisting a selection — SwitchContextEventListener
- On
SwitchContextEvent::CONSISTENT_CHECK: only acts when the request carriespickupLocationId(so payment/address/language switches never disturb it). An empty value clears the selection (remove control); a value is validated (PickupLocationValidator, anEntityExistson active + assigned-to-sales-channel) and saved viaPickupContextStorage. - On
SalesChannelContextResolvedEvent(gated on the pickup shipping method, so no lookup on other requests): re-attaches the selection as thepickupLocationcontext extension for the storefront to read.
Persistence layer — PickupContextStorage wraps SalesChannelContextPersister
(load/save/clear). It scopes the payload to the customer only when genuinely
logged in (not while an admin impersonates), so token OR customer_id migrates the
selection across the guest→customer login token change without leaking between
customers or sales channels. Keys are centralized in PickupContextKeys.
Validation gate — PayOnPickupCartProcessor (shopware.cart.validator)
- If payment is Pay-on-pickup but the shipping method is not self-pickup →
UnsupportedDeliveryMethodCartBlockerError. - If shipping is self-pickup: resolve the selection via
PickupLocationSelectionResolver(which reads the persisted store, not the transient extension). No valid location →PickupLocationRequiredCartBlockerError. A chosen time that failsPickupTimeSlotService::isBookable()→InvalidPickupTimeCartBlockerError. All blockers setblockOrder() = true. The read is idempotent across recalculations.
Order placement — OrderListener on CheckoutOrderPlacedEvent
- For pickup orders:
OrderPickupLocationWritercreates thekommandhub_order_pickup_locationrow;PickupOrderPlacedEventis dispatched; the context selection is then cleared so a new cart in the same session starts clean.
6. Pickup scheduling & availability
PickupLocationAvailabilityService (pure, stateless, reads only loaded
associations):
isOpenAt(location, ?ref)— open at an instant (special-date override wins over weekly;[open, close)half-open; supports overnight wrap).isOpenOnDate(location, ?ref)— open at any point on the reference local date (the correct check for the selection list).getOpenIntervalsForDate(location, date)— concrete{start, end}instants for a date; the seam the slot generator builds on.filterOpen/filterOpenOnDate— filter loaded collections.
PickupTimeSlotService:
getSlots(location, date, ?now)— steps each open interval byslotStepMinutes(default 30), keeping the whole slot inside the interval and excluding past + disallowed slots.isBookable(location, when)— the checkout gate:isOpenAt && isAllowed.protected isAllowed()— returnstrue; extension seam for cutoff/blackout/ capacity rules (decorate the service and override).
Timezone handling: decisions run in location.timezone (defaults to UTC when
unset/invalid). The slots controller builds the requested day at midnight in the
location's timezone so it maps to the intended calendar day for any offset. The
chosen pickup_time is stored as a DATETIME(3) (offset carried through the
ISO-8601 value the storefront submits, parsed back by the resolver).
7. Storefront integration
- Twig:
views/storefront/component/shipping/custom/shipping-method.html.twigextends the core shipping-method component and renders, under the self-pickup method, a location<select name="pickupLocationId">, a date helper input, a<select name="pickupTime">, and a<textarea name="pickupComment">. Selection reaches the context via the core shipping-form auto-submit (context switch). - The finish page (
page/checkout/finish/finish-address.html.twig) renders the pickup card fromorder.extensions.kommandhubPickupLocation. - JS plugins (
app/storefront/src/):SalesChannelPickupLocationfetches the location option list;SalesChannelPickupTimefetches time-slot options for the chosen date and keeps the date helper from auto-submitting the form. - Controller
SalesChannelPickupLocationController(storefront route scope):…pickup-locations.index— active + sales-channel-assigned locations open on the current date (renders<option>HTML). Filters bysalesChannels.idwithout hydrating the association (avoids per-row over-fetch); loads the schedule associations, batched.…pickup-locations.slots— bookable time-slot<option>HTML for a location +datequery.
8. Administration integration
- Module
kommandhub-pickup-location(under Content): list + create/edit, sales-channel assignment, and a schedule editor (kommandhub-pickup-location-schedule) for timezone + weekly intervals + special dates. - Order detail tab
kommandhub-order-pickup-info: the child routesw.order.detail.pickupis injected into the coresw.order.detailroute via a lightweight modulerouteMiddleware, and the tab item is added by overriding thesw-order-detailtemplate'ssw_order_detail_content_tabs_extensionblock. The tab component loads thekommandhub_order_pickup_locationrecord byorderId(pickupLocationassociation) and renders a read-only, deleted-location-aware view (pickup-view.jsderives the display state).
9. Flow Builder
Triggers (business events), registered with the flow collector by
BusinessEventCollectorListener:
| Event name | Class | Dispatched by | Available data |
|---|---|---|---|
pickup.order.placed |
PickupOrderPlacedEvent |
OrderListener on order placement |
order, pickupLocation, pickupOrderLocation |
pickup.order.ready |
PickupOrderReadyEvent |
PickupOrderReadyListener on delivery entering the ready state |
order, pickupLocation, pickupOrderLocation |
Both implement OrderAware, MailAware, CustomerAware, PickupLocationAware,
OrderPickupLocationAware (placed also carries sales-channel-context/group).
getMailStruct() targets the order customer, and returns an empty recipient list
when the customer/email is missing.
Flow data is restored by storers (auto-tagged flow.storer):
PickupLocationFlowStorer and OrderPickupLocationFlowStorer store only the id and
lazily reload the entity, so delayed flows stay small. pickupOrderLocation exposes
pickupTime/comment (and the linked location) as first-class flow variables.
Actions (flow.action, declared explicitly in services.yml):
action.kommandhub.pickup.notify_admin—SendPickupNotificationToAdminAction: emails the order's pickup location (recipient = location email). RequiresOrderAware+PickupLocationAware;pickupOrderLocationis optional template data. Template id comes from the action config or the plugin's seeded default.action.kommandhub.pickup.notify_sms—SendSmsToPickupLocationAction: texts the location's phone number; appends the pickup time when present.
10. Email & optional SMS
- Email: templates are seeded by migrations (
…PickupReadyMailTemplate) and sent through the coreMailServicefrom the admin-notify action. Template data:order,customer,pickupLocation,pickupOrderLocation. - SMS: a soft dependency on
KommandhubSmsSW. The gateway is injected with@?Kommandhub\SmsSW\Notification\Gateway\NotificationGatewayInterface(optional service) and used by duck typing against the localFlow\Sms\SmsGatewayinterface — this plugin does not require the SMS plugin incomposer.json. When absent, the argument isnulland the action no-ops. Do not type-hint container services againstSmsGateway.
11. Configuration
src/Resources/config/config.xml — three booleans, consumed only in storefront
Twig via config('KommandhubClickAndPickSW.config.<key>'):
| Key | Default | Effect |
|---|---|---|
enablePickupLocationSelection |
true |
gates the in-checkout selector |
showStreetNameInPickupLocationSelectionField |
false |
street in the option label |
showContactDetailInPickupLocationInfo |
true |
contact rows in the info card |
The admin-notify action reads the sender email from core system config
(SystemConfigService), not from plugin config.
12. Extension & customization points
- Slot rules: decorate
PickupTimeSlotServiceand override the protectedisAllowed()to add cutoff/blackout/capacity logic (used by both slot generation and the checkout gate). - Twig blocks: the storefront shipping-method override and the info card are
fully block-wrapped; the admin
sw-order-detailtab uses the core extension block. - Flow contracts:
PickupLocationAware/OrderPickupLocationAwarelet other pickup-related events reuse the same storers and actions. - Reuse services, don't reach across modules:
PickupLocationSelectionResolver(context → resolved selection),PickupLocationAvailabilityService,PickupContextStorage,OrderPickupLocationWriter.
13. Installation & compatibility (developers)
shopware/coreandshopware/storefront~6.7.0.- PHP 8.2+ (CI runs 8.2; the dev Docker image is
dockware/shopware:6.7.8.0).
Composer
Package: kommandhub/click-and-pick-sw. It is proprietary and not on public
Packagist, so first make it resolvable — add Kommandhub's private Composer
registry, or the Git repository as a VCS source, to your project's
composer.json:
"repositories": [ { "type": "vcs", "url": "git@github.com:KommandHub/KommandhubClickAndPickSW.git" } ]
Then require and enable it from the Shopware project root:
composer require kommandhub/click-and-pick-sw bin/console plugin:refresh bin/console plugin:install --activate KommandhubClickAndPickSW bin/console cache:clear
Update an installed copy:
composer update kommandhub/click-and-pick-sw bin/console plugin:refresh bin/console plugin:update KommandhubClickAndPickSW bin/console cache:clear
Without registry/VCS access, install from a release archive by unpacking it into
custom/plugins/KommandhubClickAndPickSW, then run the same plugin:refresh /
plugin:install --activate commands. Rebuild assets afterwards when needed
(bin/build-administration.sh && bin/build-storefront.sh).
Lifecycle
Install/activate lifecycle (KommandhubClickAndPickSW):
install/update→ run the payment + shipping installers (idempotent).activate/deactivate→ activate/deactivate the payment + shipping methods.- Migrations create all tables/state/mail/flow entries.
14. Migrations, update & uninstall
Ordered by timestamp:
1759696668PickupLocation— location + sales-channel mapping tables (+activeindex).1760109246AddReadyForPickupOrderState— pickup-readyreadyorder_deliverystate + transitions.1760113852PickupReadyMailTemplate— mail template types/templates.1760115677AddPickupMailSendFlow— flow that sends the customer pickup-ready mail.1760200000AddPickupAdminNotificationFlow— flow for the admin pickup-order-placed mail.1760300000NormalizedPickupSchedule—timezonecolumn + opening-hour/special-hour tables.1760400000OrderPickupLocation— order pickup record table.
Update: update() re-runs the installers so the payment/shipping methods
migrate if their ids/classes move; each installer is idempotent.
Uninstall: payment and shipping methods are deactivated, never deleted
(historical orders reference them). Without "keep user data", the five plugin tables
are dropped child-first (order_pickup_location → opening/special hours →
sales-channel mapping → pickup_location) so foreign keys don't block removal.
Orders are never deleted.
15. Performance, caching & indexing
- Selection lives in the context payload → no cart-payload bloat; reads are single
indexed lookups scoped to
(token/customer, sales_channel). - The context-resolved listener is gated on the pickup shipping method, so non-pickup requests do zero pickup work.
- Storefront
index()filters bysalesChannels.idwithout hydrating the association (one fewer batched query + noSalesChannelEntityhydration per row); to-many schedule associations load batched (IN(...)), so there is no N+1. - Indexing:
pickup_location.active; mapping PK + FK auto-index cover both directions (InnoDB appends the PK, so a single-column index is effectively(col, id)); schedule aggregates indexed on(pickup_location_id, day_of_week|date); order pickup record unique on(order_id, order_version_id)+ index onpickup_location_id. - Availability is computed in PHP (per-location timezone) rather than SQL, so results are not cacheable across timezones — the working sets are small (a merchant's locations), and queries stay index-served.
16. Error handling, logging & debugging
- Cart problems surface as blocking
Errors (see §5) with message keys undercheckout.kommandhub-click-and-pick.*/error.…. - An invalid/foreign/inactive location selection throws a
ConstraintViolationExceptionat the context switch (aborts the switch). - Flow actions inject a PSR
loggerand log-and-swallow provider failures (missing template, mail/SMS send errors) so a flow run is not aborted by a notification. - Debugging tips: the selection is in
sales_channel_api_context.payload(pickupLocationId/pickupTime/pickupComment); the resolved selection is what the cart validator sees; the order record is inkommandhub_order_pickup_locationand onorder.extensions.kommandhubPickupLocation.
17. Local development
The plugin ships its own dev stack (docker-compose.yml, Dockerfile, Makefile).
Run from the plugin root:
make up # start the dev stack make shell # bash into the app container make test # PHPUnit (phpunit.dist.xml). Filter: make test FILTER=SomeTest make test-coverage # coverage (text + clover + html) make analyse # PHPStan (phpstan.dist.neon), src only make cs / cs-fix # php-cs-fixer dry-run / apply make validate-plugin # shopware-cli store compliance (needs make build) make zip # distributable package into build/ (needs make build) make help # full target list
Assets (generated; rebuild after changing admin/storefront source):
bin/build-administration.sh && bin/build-storefront.sh
18. Testing
tests/Unit/mirrorssrc/; no kernel, PHPUnitTestCasewith mocks.tests/Integration/is kernel-backed and tagged#[Group('kernel')](e.g.CartValidationTest); it needs a booted Shopware + database.- Run all:
make test. No-kernel (CI-equivalent):--exclude-group kernel. - 100% line coverage is enforced in CI (
coverage-check … 100); coverage source issrc/excludingMigration/,Resources/,DependencyInjection/,Entity/, and the plugin bootstrap.beStrictAboutCoverageMetadatais on — every test declares#[CoversClass]/#[UsesClass].
19. Static analysis & coding standards
- PHPStan level 9 (
phpstan.dist.neon), analysingsrc/. - php-cs-fixer (
.php-cs-fixer.dist.php) and aphpcs.dist.xmlruleset. - CI (
.github/workflows/php.yml): composer validate → PHP lint → PHPStan → cs-fixer (dry-run) → PHPUnit (no-kernel) with the 100% coverage gate. - Run locally before committing:
make cs-fix && make analyse && make test.
20. Known technical limitations
- Slot generation offers times but does not enforce capacity/quota, lead-time/
cutoff, or blackout windows — these are the
isAllowed()extension seam (§12). - Availability is timezone-evaluated in PHP and not cross-timezone cacheable (small working sets; queries stay index-served).
- The SMS integration is best-effort and duck-typed; there is no compile-time
guarantee the optional plugin's gateway matches
SmsGateway. - Built admin/storefront assets must be regenerated as a release/packaging step.
21. Contributing
See CONTRIBUTING.md for the branch/commit conventions and the required quality
gates. In short: branch off develop, keep the change inside its feature module,
add/adjust tests with each behavior change, and ensure make cs-fix && make analyse && make test (100% coverage) passes before opening a PR.
22. License
Proprietary — © Kommandhub Limited. Licensed, not sold, for use with Shopware 6
under a separate commercial agreement. See LICENSE. Security policy: SECURITY.md.