How to Build a Custom Reserve in Store / Click & Collect Module in Magento 2

In this post I’ll show you how to build a custom "Reserve in Store" (Click & Collect) module for Magento 2. Think of this as a practical walk-through you can follow step-by-step while sipping coffee — I’ll explain the architecture, the key files and folders, how to integrate with checkout and stock, admin UI for managing stores and reservations, notifications, and a few performance tips to keep your site snappy. Examples include concrete XML, PHP and JavaScript snippets you can copy and adapt for your project.
Why build a custom Click & Collect module?
Built‑in Magento features might not fit every merchant’s needs: different pickup workflows, special store rules, varying stock handling with MSI, or bespoke emails and reminders. Building your own module means you control UX and logic — and you can integrate closely with your inventory strategy and your checkout.
High-level features we’ll cover
- Store selection during checkout (pickup point chooser)
- Persisting pickup selection on quote/order
- Admin UI to manage participating stores and reservations
- Reservation lifecycle: creation, confirmation email, pickup reminder
- Stock management integration (MSI-aware suggestions)
- Performance considerations (cache, debounce, async checks)
Module architecture
For clarity, we’ll use a conventional module name: Magefine_ReserveInStore. Keep files organized and follow Magento conventions so the module is maintainable and easy to extend later.
Key directories and responsibilities:
- etc/: module.xml, adminhtml/frontend routes, di.xml and events
- registration.php and composer.json
- Controller/ (frontend & adminhtml) for actions
- Model/ResourceModel/ for reservation and store models
- view/frontend/layout and view/frontend/web/js for checkout UI
- view/adminhtml/ui_component for grid and form UI
- etc/db_schema.xml to create tables for stores and reservations
- Observer/Plugin for checkout integration and order convert
Basic module registration
<?php // app/code/Magefine/ReserveInStore/registration.php \Magento\Framework\Component\ComponentRegistrar::register( \Magento\Framework\Component\ComponentRegistrar::MODULE, 'Magefine_ReserveInStore', __DIR__ );
<?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> <module name="Magefine_ReserveInStore" setup_version="1.0.0" /> </config>
Database schema (db_schema.xml)
We’ll create two tables: magefine_reserve_store (stores list) and magefine_reservation (reservation records tied to a quote/order). Using db_schema.xml is the modern way (no install/upgrade scripts).
<?xml version="1.0"?> <schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="magefine_reserve_store" resource="default" engine="innodb" comment="Reserve In Store - Stores"> <column xsi:type="int" name="store_id" nullable="false" unsigned="true" identity="true" /> <column xsi:type="varchar" name="name" nullable="false" length="255" /> <column xsi:type="varchar" name="code" nullable="false" length="64" /> <column xsi:type="text" name="address" nullable="true" /> <column xsi:type="varchar" name="phone" nullable="true" length="64" /> <column xsi:type="smallint" name="active" nullable="false" default="1" /> <constraint referenceId="PRIMARY" type="primary" columnList="store_id" /> </table> <table name="magefine_reservation" resource="default" engine="innodb" comment="Reserve In Store - Reservations"> <column xsi:type="int" name="reservation_id" nullable="false" unsigned="true" identity="true" /> <column xsi:type="int" name="quote_id" nullable="true" unsigned="true" /> <column xsi:type="int" name="order_id" nullable="true" unsigned="true" /> <column xsi:type="int" name="store_id" nullable="false" unsigned="true" /> <column xsi:type="varchar" name="status" nullable="false" length="32" default="pending" /> <column xsi:type="timestamp" name="created_at" nullable="false" on_update="false" default="CURRENT_TIMESTAMP" /> <column xsi:type="timestamp" name="pickup_by" nullable="true" /> <constraint referenceId="PK_RESERVATION" type="primary" columnList="reservation_id" /> </table> </schema>
This structure keeps reservation separate from quotes/orders, which avoids changing core tables and makes the logic portable.
Models and Resource Models
Create simple Model/ResourceModel classes for Store and Reservation. Example for Reservation model skeleton:
<?php namespace Magefine\ReserveInStore\Model; use Magento\Framework\Model\AbstractModel; class Reservation extends AbstractModel { protected function _construct() { $this->_init(\Magefine\ReserveInStore\Model\ResourceModel\Reservation::class); } }
<?php namespace Magefine\ReserveInStore\Model\ResourceModel; use Magento\Framework\Model\ResourceModel\Db\AbstractDb; class Reservation extends AbstractDb { protected function _construct() { $this->_init('magefine_reservation', 'reservation_id'); } }
Admin interface: managing stores and reservations
Use UI Components for admin grids and forms. Create ui_component XML for the store grid (view/adminhtml/ui_component/magefine_reserve_store_listing.xml) and for reservation listing. The admin needs to:
- Create and edit pickup stores (name, address, code, active)
- See reservations, filter by status, export, and change status (e.g. confirmed, picked_up, cancelled)
Quick example: a form for store entity (a simplified UI Component snippet):
<form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd"> <dataSource name="store_form_data_source"> <argument name="dataProvider" xsi:type="configurableObject"> <argument name="class" xsi:type="string">\Magefine\ReserveInStore\Ui\Component\Store\DataProvider </argument> </dataSource> <fieldset name="general" sortOrder="10"> <field name="name"><settings><dataType>text</dataType><label>Store Name</label></settings></field> <field name="code"><settings><dataType>text</dataType><label>Code</label></settings></field> </fieldset> </form>
Integrating into checkout
This is the trickiest part. You must let customers choose pickup in checkout and persist it to quote so the backend can create a reservation when the order is placed.
Two common UX patterns:
- Add a shipping method called "Pick up in store" (simpler if you already have a shipping method plugin system); or
- Add a checkout step or a small UI component on the shipping step that shows a "Pickup" toggle and store selector (modern and less invasive).
I prefer adding a small UI component on the shipping step using Knockout (Magento checkout is powered by KO). Steps:
- Add layout: app/code/Magefine/ReserveInStore/view/frontend/layout/checkout_index_index.xml
- Provide JS component and template in view/frontend/web/js/view
- Persist selection to quote via an AJAX call to a frontend controller or via quote API
<?xml version="1.0"?> <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceContainer name="checkout.root"> <block name="reserve.in.store" template="Magento_Theme::html/blank.phtml"> <arguments> <argument name="jsLayout" xsi:type="array"> <item name="components" xsi:type="array"> <item name="checkout" xsi:type="array"> <item name="children" xsi:type="array"> <item name="steps" xsi:type="array"> <item name="children" xsi:type="array"> <item name="shipping-step" xsi:type="array"> <item name="children" xsi:type="array"> <item name="reserve-in-store" xsi:type="array"> <item name="component" xsi:type="string">Magefine_ReserveInStore/js/view/pickupbefore-shipping-method-formThe JS component (very simplified) might look like this:
define([ 'uiComponent', 'ko', 'mage/storage', 'Magento_Checkout/js/model/quote' ], function (Component, ko, storage, quote) { 'use strict'; return Component.extend({ defaults: { template: 'Magefine_ReserveInStore/pickup' }, isPickup: ko.observable(false), selectedStore: ko.observable(null), stores: ko.observableArray([]), initialize: function () { this._super(); var self = this; // load available stores via REST or controller storage.get('reserveinstore/index/stores').done(function (response) { self.stores(response); }); self.isPickup.subscribe(function (value) { // when toggled, persist to server (quote meta table) storage.post('reserveinstore/quote/save', JSON.stringify({is_pickup: value})); }); self.selectedStore.subscribe(function (storeId) { storage.post('reserveinstore/quote/save', JSON.stringify({store_id: storeId})); }); } }); });The frontend controller endpoints (reserveinstore/index/stores and reserveinstore/quote/save) will return the list of active stores and persist the chosen option to a helper table linked by quote_id. In the backend we’ll read that on order place and create a reservation record.
Persisting data from quote to order
We want to create a reservation record when an order is placed. Use an observer on sales_model_service_quote_submit_success (or the order_place_after) event. Read the quote ID, find the stored pickup metadata, then create Magefine\ReserveInStore\Model\Reservation with order_id and change status to confirmed.
<?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd"> <event name="checkout_submit_all_after"> <observer name="magefine_create_reservation" instance="Magefine\ReserveInStore\Observer\CreateReservation" /> </event> </config><?php namespace Magefine\ReserveInStore\Observer; use Magento\Framework\Event\Observer; use Magento\Framework\Event\ObserverInterface; class CreateReservation implements ObserverInterface { protected $reservationFactory; protected $quoteReservationRepository; // a model that reads the quote temp table public function __construct( \Magefine\ReserveInStore\Model\ReservationFactory $reservationFactory, \Magefine\ReserveInStore\Model\QuoteReservationRepository $quoteReservationRepository ) { $this->reservationFactory = $reservationFactory; $this->quoteReservationRepository = $quoteReservationRepository; } public function execute(Observer $observer) { $order = $observer->getEvent()->getOrder(); if (!$order) { return; } $quoteId = $order->getQuoteId(); $data = $this->quoteReservationRepository->getByQuoteId($quoteId); if (!empty($data) && !empty($data['store_id'])) { $reservation = $this->reservationFactory->create(); $reservation->setData([ 'quote_id' => $quoteId, 'order_id' => $order->getId(), 'store_id' => $data['store_id'], 'status' => 'confirmed' ]); $reservation->save(); } } }Stock management and MSI
Handling stock is critical: if you allow customers to reserve items for pickup they shouldn’t be available for other buyers, otherwise you’ll oversell. There are two options:
- Perform a source deduction / create a reservation in Magento MSI when the order is placed (preferred); or
- Create an internal reservation and periodically sync with stock (less safe).
With Magento 2.3+ MSI: the recommended approach when an order is placed is to create a reservation (inventory reservation system) or directly deduct from the source for the store if you manage per-store source stocks. If you don’t use MSI, you can update cataloginventory_stock_item.
Example idea for MSI-aware approach (pseudo-code):
// In the CreateReservation observer, after saving reservation // 1) determine source for the chosen store // 2) call a safe service that creates a reservation or source deduction $sourceCode = $this->storeSourceResolver->getSourceCodeByStoreId($data['store_id']); $items = $order->getAllVisibleItems(); foreach ($items as $item) { $sku = $item->getSku(); $qty = (int) $item->getQtyOrdered(); // Use Inventory APIs (InventoryReservationInterface or SourceDeductionService) $this->inventoryService->createReservation($sku, $sourceCode, $qty, 'reserve_in_store', $order->getIncrementId()); }
Note: the MSI API has InventoryReservationInterface and commands like InventoryUploadReservation. Exact class names may vary by Magento version; check your installed core for correct interfaces. If you are unsure, use the SourceDeductionService for direct source deductions. Always wrap deductions in try/catch and fail gracefully (set reservation status to failed and notify admin).
Admin actions & notifications
Admins should be able to confirm reservations, mark them as picked up, or cancel them. For notifications we’ll implement:
- An email on reservation creation (confirmation) to the customer
- A reminder email a configurable number of hours before pickup_by
Create email templates (view/frontend/email) and use Magento\Framework\Mail\Template\TransportBuilder to send them. Example snippet to send a simple confirmation email:
/** @var \Magento\Framework\Mail\Template\TransportBuilder $transportBuilder */ $transport = $this->transportBuilder ->setTemplateIdentifier('reserveinstore_confirmation_template') ->setTemplateOptions(['area' => 'frontend', 'store' => $order->getStoreId()]) ->setTemplateVars(['order' => $order, 'reservation' => $reservation]) ->setFromByScope('general') ->addTo($order->getCustomerEmail()) ->getTransport(); $transport->sendMessage();
For reminders, create a cron job that runs hourly and sends reminders for reservations where pickup_by is approaching and status is still confirmed. Cron is defined in etc/crontab.xml and the cron method should batch process reservations and mark when reminder is sent to avoid duplicates.
Admin UI: example flow
- Admin creates stores (name, address, phone, code, active)
- Orders with pickup are visible in the Reservation grid; filters show store, status, created_at
- Admin can manually reserve stock (or check reservation failed logs) and mark reservation as ready or picked_up
- When marked picked_up, the reservation becomes complete and any temporary stock holds are finalized
Performance and caching
Click & Collect often requires real-time stock checks per store and per SKU, which can be expensive. A poorly implemented real-time check can create DB and API bloat. Here are pragmatic recommendations:
- Debounce front-end calls. When customers type or change quantities, wait 300-500ms before hitting the server.
- Batch SKU checks. If your cart has 5 items, query availability in one request, not five separate ones.
- Use Redis cache for availability snapshots. Cache per (store, sku) with a short TTL (e.g. 30s). That gives near real-time accuracy and reduces load spikes.
- Avoid full joins on large tables for every request. Use indexed columns and proper resource model queries. Create small, focused read tables if necessary for fast lookups.
- When using MSI: avoid calling heavy inventory indexers during customer flows. Use precomputed salable_qty values served via fast queries or caching layers.
- Rate-limit endpoints to avoid abuse (e.g., 10 calls per second per IP). You can implement a simple token bucket in Redis for this.
- Use asynchronous processing for non-blocking tasks: sending emails or creating complex stock deductions can be queued (Message Queue or custom cron jobs) so checkout is snappy.
Edge cases and pitfalls
- Partial shipments: if only part of the order is available at the pickup store, decide how you’ll notify the customer and whether to allow partial pickup.
- Cancelled orders and stock reconciliation: ensure cancellations release any holds or reservations.
- Multiple stores/one SKU: customers should only choose stores that have the items available, or you should show an estimated availability. Don’t allow selecting a store with zero stock unless you have a backorder flow.
- Cart changes after selecting pickup: revalidate availability if quantities change and prompt customer to confirm.
Monitoring and logs
Log all reservation attempts and failures. Monitor these logs and create simple dashboards that tell you how many reservations are pending, which stores have frequent failures, and if MSI deductions are failing. This helps you react fast when a source goes offline or a process fails.
Example: simple flow summary and code mapping
Mapping from concept to files (quick reference):
- Frontend UI component: view/frontend/web/js/view/pickup.js + template knockout file
- Frontend controllers: Controller/Index/Stores.php (return JSON), Controller/Quote/Save.php (persist pick-up selection)
- Admin UI: view/adminhtml/ui_component/magefine_reserve_store_listing.xml, forms in view/adminhtml/ui_component/***
- Models: Model/Store, Model/Reservation + ResourceModel
- Observer: CreateReservation (hooked to checkout_submit_all_after)
- Stock service: Service/Inventory/SourceDeductionService.php that talks with MSI interfaces
- Email templates: view/frontend/email/reservation_confirmation.html
- Cron: etc/crontab.xml -> Cron/ReminderSender.php
SEO and content tips for magefine.com
Because this content should attract Magento developers and store owners, sprinkle the post with the right keywords — "Magento 2 click and collect", "Reserve in Store Magento 2", "MSI store reservations", "Magento module development" — but keep readability first. Use code blocks for clarity (as above) and include an architecture diagram if possible in your CMS. Make sure you include internal links to related Magefine pages (your extension listing or hosting pages) on the final published page, but do not link to non-existent extensions.
Testing checklist
- End-to-end: select pickup, place order, confirm reservation created and stock adjusted
- Edge: change cart after selecting pickup — verify availability recheck
- Failure: simulate MSI API failure and verify reservation status 'failed' and admin notification
- Performance: load test the availability endpoint with cached and uncached scenarios
Tips for packaging and releasing
- Keep module composer-friendly (composer.json). Make installation with composer require magefine/reserveinstore easy.
- Provide config options in admin (Stores > Configuration > Magefine > ReserveInStore) for enabling, default pickup lead time, reminder offset, default active stores and caches TTL.
- Create DB upgrades safely if you later change schema; keep db_schema.xml consistent for new installations and use declarative schema for upgrades.
Final thoughts
Building a robust Reserve in Store module is an excellent way to add real business value to a Magento store; the key is to keep concerns separated (quote meta, reservations table), respect MSI if you use it, and design the checkout UX so it’s non-disruptive. Focus on fast lookups and caching to avoid adding load to peak times. If you keep these points in mind the module will be reliable and easy to support.
If you want, I can generate the full file tree and more exact code (complete controllers, admin grids, templates and full MSI integration) tailored to your Magento version (2.3.x, 2.4.x), or prepare a composer-ready starter repo matching this architecture.