Appearance
Architecture & data model
For developers
Namespace Kommandhub\ClickAndPickSW\ → src/. Plugin class Kommandhub\ClickAndPickSW\KommandhubClickAndPickSW. Shopware ~6.7.0.
Design decisions
- DAL-first. Pickup data lives in dedicated 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 choice is stored in the
sales_channel_api_contextpayload viaSalesChannelContextPersister— the same store as the selected shipping and payment method — so it survives the guest → customer login token change and stays scoped per customer and sales channel. The cart validator reads the persisted store, never a request-local extension. - Time-zone correct. Every open/closed decision runs in PHP in the location's IANA time zone; opening times are local wall-clock strings.
Data model
| Table | Entity | Notes |
|---|---|---|
kommandhub_pickup_location | PickupLocationDefinition | name, address, email, phone, time_format, timezone, latitude/longitude, location_code, active |
kommandhub_pickup_location_sales_channel | mapping | M2M to sales_channel |
kommandhub_pickup_location_opening_hour | OpeningHourDefinition | day_of_week (ISO 1–7), open_time, close_time (HH:MM) |
kommandhub_pickup_location_special_hour | SpecialHourDefinition | date, closed, optional open_time/close_time |
kommandhub_order_pickup_location | OrderPickupLocationDefinition | one row per pickup order: order_id + order_version_id, nullable pickup_location_id, pickup_time (DATETIME(3)), comment |
Associations:
order.extensions.kommandhubPickupLocation— OneToOne, autoloaded, cascade delete. Every order read (finish page, account, Admin API, flow data) carries the pickup record without extra criteria.- Order pickup record → location:
ON DELETE SET NULL, so deleting a location keeps the order's time and comment.
Access through the generated repositories, e.g. kommandhub_pickup_location.repository.
Checkout lifecycle
text
Shipping form submit (context switch) with pickupLocationId / pickupTime / pickupComment
└─ SwitchContextEventListener (CONSISTENT_CHECK)
only when the request carries pickupLocationId; empty clears it
PickupLocationValidator: exists, active, assigned to this sales channel
→ else ConstraintViolationException (switch aborted)
PickupContextStorage::save()
SalesChannelContextResolvedEvent (only when the pickup shipping method is selected)
└─ attach the selection as context extension "pickupLocation" for the storefront
Cart validation — PayOnPickupCartProcessor (shopware.cart.validator)
pay-on-pickup without self pick-up → UnsupportedDeliveryMethodCartBlockerError
self pick-up, no valid persisted location → PickupLocationRequiredCartBlockerError
time not bookable → InvalidPickupTimeCartBlockerError
CheckoutOrderPlacedEvent — OrderListener
write kommandhub_order_pickup_location, dispatch PickupOrderPlacedEvent,
clear the selection so the next cart starts cleanAvailability
PickupLocationAvailabilityService (pure, reads only loaded associations):
isOpenAt(location, ?ref)— special date wins over weekly; intervals are half-open[open, close); overnight intervals wrap.isOpenOnDate(location, ?ref)— open at any point on that local date (used for the checkout list).getOpenIntervalsForDate(location, date),filterOpen(),filterOpenOnDate().
PickupTimeSlotService:
getSlots(location, date, ?now)— steps each open interval by 30 minutes (DEFAULT_STEP_MINUTES), keeping the whole slot inside the interval, excluding past and disallowed slots.isBookable(location, when)—isOpenAt && isAllowed; the checkout gate.protected isAllowed()— returnstrue. The extension seam, see Extending.
Storefront
views/storefront/component/shipping/custom/shipping-method.html.twigrenders the location<select name="pickupLocationId">, a date input,<select name="pickupTime">and<textarea name="pickupComment">under the self pick-up method. The core shipping-form auto-submit carries them into the context switch.- Finish page:
page/checkout/finish/finish-address.html.twigrenders the pickup card fromorder.extensions.kommandhubPickupLocation. - JS plugins
SalesChannelPickupLocationandSalesChannelPickupTimeload<option>HTML from two storefront routes (…pickup-locations.indexand…pickup-locations.slots?date=YYYY-MM-DD).
Administration
- Module
kommandhub-pickup-locationunder Content: list, create/edit, sales-channel assignment, schedule editor. - Order tab: child route
sw.order.detail.pickupinjected intosw.order.detail; tab added via thesw_order_detail_content_tabs_extensionblock. - ACL key
kommandhub_pickup_location(viewer/editor/creator/deleter).
Flow Builder
| Trigger | Class | Aware of |
|---|---|---|
pickup.order.placed | Event\PickupOrderPlacedEvent | Order, Mail, Customer, PickupLocation, OrderPickupLocation (+ sales-channel context) |
pickup.order.ready | Event\PickupOrderReadyEvent | Order, Mail, Customer, PickupLocation, OrderPickupLocation |
Storers PickupLocationFlowStorer and OrderPickupLocationFlowStorer store only IDs and reload lazily, so delayed flows stay small.
| Action | Class |
|---|---|
action.kommandhub.pickup.notify_admin | Flow\Action\SendPickupNotificationToAdminAction — mail to the location email; template from config or the seeded default |
action.kommandhub.pickup.notify_sms | Flow\Action\SendSmsToPickupLocationAction — SMS to the location phone |
The SMS action takes @?Kommandhub\SmsSW\Notification\Gateway\NotificationGatewayInterface — an optional service. The plugin does not require the SMS plugin in composer.json; without it the argument is null and the action no-ops. It is duck-typed against the local Flow\Sms\SmsGateway interface, so there is no compile-time guarantee the two stay compatible.
Migrations
| Migration | Creates |
|---|---|
1759696668PickupLocation | location + sales-channel mapping tables |
1760109246AddReadyForPickupOrderState | delivery state ready; transitions open→ready (ready), ready→open (reopen), ready→shipped (ship), ready→cancelled (cancel) |
1760113852PickupReadyMailTemplate | mail template types and templates (en-GB, de-DE) |
1760115677AddPickupMailSendFlow | active flow on state_enter.order_delivery.state.ready |
1760200000AddPickupAdminNotificationFlow | active flow on pickup.order.placed |
1760300000NormalizedPickupSchedule | timezone column, opening/special hour tables |
1760400000OrderPickupLocation | order pickup record table |
Lifecycle: install/update run the payment and shipping installers (idempotent); activate/deactivate toggle both methods. Uninstall deactivates the methods (never deletes them) and, without keep user data, drops the five plugin tables child-first. Orders are never deleted.
Development setup & testing
bash
git clone https://github.com/KommandHub/KommandhubClickAndPickSW.git
cd KommandhubClickAndPickSW
make up # Shopware + this plugin in container `click-and-pick-plugin`
make shell # then: bin/console plugin:install --activate KommandhubClickAndPickSW
make cs-fix && make analyse && make test # the gate to pass before every commit
make test FILTER=SomeTest
make test-coverage
make validate-plugin # Shopware Store compliance (shopware-cli, inside the container)
make zip # release ZIP into build/tests/Unitmirrorssrc/and needs no kernel; tests that boot Shopware carry#[Group('kernel')]and are excluded in CI.- CI (GitHub Actions,
.github/workflows/php.yml) runs composer validate, PHP lint, PHPStan level 9, php-cs-fixer and PHPUnit, and enforces 100 % line coverage. - Coverage excludes
Migration/,Resources/,DependencyInjection/,Entity/and the bootstrap. Every test declares#[CoversClass]/#[UsesClass](beStrictAboutCoverageMetadata). make downdeletes the stack's database volume; the stack publishes no host port by default (addports: ["80:80"]to reach the shop in a browser). Shared tooling: Development environment.