Before streaming, Netflix mailed DVDs. You kept a list of films you wanted. Your plan allowed a set number of discs at home at once. When you mailed one back, the next film on your list that was in stock went out. Nobody picked dates. The list and the free slot decided everything.
That model still fits shops that lend board games, camera lenses or toys on a membership. We build Rentals & Bookings, a rental plugin for WooCommerce, and a rental extension for Magento 2. Our Subscriptions & Memberships add-on has a rental queue on both platforms. This post walks through how it works, with code from the released versions: the WooCommerce add-on 1.0.9 and the Magento 2 module 1.0.3. The two were built months apart, and they made some different choices. We'll point those out as we go.
Three things to store
A queue needs three kinds of record:
- Queue items: what a member wants, in order.
- Loans: what is out with a member now, and what came back.
- Slots: how many loans a membership allows at once.
WooCommerce keeps queue items and sends in two small tables. The slot count sits on the membership row:
-- WooCommerce add-on 1.0.9, created on activation (trimmed; default wp_ prefix)
CREATE TABLE wp_sisubscriptions_queue (
queue_id BIGINT UNSIGNED NOT NULL auto_increment,
user_id BIGINT,
product_id BIGINT UNSIGNED,
priority BIGINT UNSIGNED,
PRIMARY KEY (queue_id)
);
CREATE TABLE wp_sisubscriptions_queue_sent (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT(20) UNSIGNED NOT NULL,
membership_id BIGINT(20) UNSIGNED NOT NULL,
product_id BIGINT(20) UNSIGNED NOT NULL,
booking_id BIGINT(20) UNSIGNED NOT NULL,
date_sent DATETIME NOT NULL,
PRIMARY KEY (id)
);
-- the slot count is a column of wp_sisubscriptions_memberships:
-- queue_items INT(11) DEFAULT NULL
There is no loan table. On WooCommerce a sent item is a booking in the rental engine, starting now and ending two years out. The engine already counts stock against bookings, so the queue doesn't need its own idea of "out". An item counts as out while its booking has quantity_sent - quantity_returned > 0.
Magento has a loan table of its own, with a status:
<!-- Magento 2 module 1.0.3, etc/db_schema.xml (trimmed) -->
<table name="sisub_queue_item" resource="default" engine="innodb" comment="Rental queue">
<column xsi:type="int" name="queue_item_id" unsigned="true" nullable="false" identity="true"/>
<column xsi:type="int" name="customer_id" unsigned="true" nullable="false"/>
<column xsi:type="int" name="product_id" unsigned="true" nullable="false"/>
<column xsi:type="int" name="position" unsigned="true" nullable="false" default="1" comment="Priority, 1 = next"/>
<constraint xsi:type="unique" referenceId="SISUB_QUEUE_ITEM_CUSTOMER_ID_PRODUCT_ID">
<column name="customer_id"/>
<column name="product_id"/>
</constraint>
</table>
<table name="sisub_queue_loan" resource="default" engine="innodb" comment="Rental queue loans (items out with members)">
<column xsi:type="int" name="loan_id" unsigned="true" nullable="false" identity="true"/>
<column xsi:type="int" name="membership_id" unsigned="true" nullable="true" comment="Membership whose slot it uses"/>
<column xsi:type="varchar" name="status" nullable="false" length="16" default="out" comment="out|returned|lost|cancelled"/>
<column xsi:type="int" name="reservation_id" unsigned="true" nullable="true"/>
<column xsi:type="text" name="serials" nullable="true" comment="Serials sent"/>
<column xsi:type="datetime" name="due_back_at" nullable="true" comment="Return reminder due (UTC)"/>
<!-- ... -->
</table>
The membership row carries queue_slots and swaps_per_period, a cap on sends per billing period (0 means no cap). "Out" is a count of that membership's loans with status out.
Both designs work. On WooCommerce the queue can't disagree with the booking calendar, because a loan is a booking. On Magento a loan has a life of its own: it can be lost, carry a return-reminder date, and keep its history. The price is keeping the loan and the rental reservation in step, which the rental bridge does with an observer (more on that under returns).
Who gets what next
On send day, staff want one list: for every member with a free slot, the first item in their queue that is in stock. Magento calls it the pick list:
// QueueService::pickList(), Magento 2 module 1.0.3 (trimmed)
public function pickList(int $limit = 500): array
{
$out = [];
foreach ($this->queueingCustomers() as $customerId) {
[$membershipId] = $this->membershipForSend($customerId);
if ($membershipId === null) {
continue;
}
foreach ($this->items($customerId) as $item) {
$available = $this->adapter->available($item->getProductId());
if ($available > 0) {
// ... load the product, then add one row:
// customer_id, queue_item_id, product_id, name, sku, position, available
break;
}
}
if (count($out) >= $limit) {
break;
}
}
return $out;
}
Two rules hide in there. An item with no free unit is passed over: if a member's first choice is out, their second choice goes. And membershipForSend() takes the oldest active membership with a free slot, so a member with two memberships fills them in order. On Magento it also checks the sends-per-period cap and gives a distinct reason when that is what stops the send.
Admin tools can read the same list over GraphQL (ACL SalesIgniter_Subscriptions::queue):
query {
sisubQueuePickList(limit: 50) {
customer_id
queue_item_id
sku
name
position
available
}
}
WooCommerce has the same rule in RentalQueue::nextToSend():
// WooCommerce add-on 1.0.9
public function nextToSend( $user_id ) {
foreach ( $this->getQueueForUser( $user_id ) as $item ) {
if ( $this->getAvailableToSend( $item->product_id ) > 0 ) {
return $item;
}
}
return null;
}
It drives the Send next for all button on the Rental Queue Send screen. The button asks first, then sends each member with a free slot their next in-stock item, and the notice gives one line per member: what went, or why nothing did. The per-customer Send screen shows the same answer as a column, "Available to Send", with "None: units reserved now or in the future" in red.
Watch the naming. WooCommerce's Send pick list screen lists what was sent in a date range: a packing list with a CSV download. Magento's Pick list is what to send next. Same words, two jobs.
Availability for a loan with no end date
A normal rental asks "is a unit free from the 3rd to the 7th?". A queue loan has no end date. The member keeps the item until they send it back. So both platforms ask a stricter question: is a unit free from now until far in the future?
WooCommerce uses two years:
// WooCommerce add-on 1.0.9, RentalQueue (trimmed)
const OUT_PERIOD = '+2 years';
public function getAvailableToSend( $product_id ) {
if ( ! wc_get_product( $product_id ) ) {
return 0;
}
$start = Utility::nowString();
$end = ( new Carbon( $start ) )->modify( self::OUT_PERIOD )->format( 'Y-m-d H:i:s' );
// ...
return max( 0, (int) \Sibooking\Api::available( $product_id, $start, $end ) );
}
Magento's rental bridge asks rental stock about the window from now to now plus the "hold horizon" (730 days by default). It widens that window by the product's turnover time on both sides, and counts per inventory source when the store ships from several.
The effect: a lens with an ordinary booking next March can't go out on a queue loan today, even if it is on the shelf. Nobody knows when the member will send it back, so lending it now would be a bet against the March customer. The shop gives up some use of its stock and never double-books.
Checking and then writing leaves a gap two requests can both slip through. On WooCommerce the booking write goes through the base plugin's locked write: Api::writeBooking() takes the product's checkout lock and runs a stillFree callback inside it before it inserts. On Magento the rental adapter checks availability again inside send(), and the loan, the rental reservation and the removal from the queue are one database transaction, so a send that fails halfway leaves nothing behind.
Sending with serial numbers
Serial-tracked products need a specific unit on the loan. The rules differ a little:
- WooCommerce. Staff pick a serial on the Send screen. The code accepts only serials whose status is Available, so a unit in maintenance or out on another rental can't leave through a posted form: "Not available to send: SN-0042. Pick a serial number that is in stock." Send next for all takes the first free serial.
-
Magento. The rental adapter needs exactly one serial for a serial-tracked product ("Choose one serial number to send …"), and
sendNext()takes the first one rental offers.
On WooCommerce the hand-over can still be refused. The add-on asks Rentals & Bookings Regular to record the send, and Regular can say no: an unpaid balance, a missing signature, or staff from another location. The add-on then frees the reservation it just wrote and leaves the item in the queue, with Regular's reason on screen.
Returns: scan or paste a pile of serials
Returns arrive in a pile. Both platforms take a box of serials, one per line or comma-separated, and close the matching loans one by one, with one result per serial.
WooCommerce finds the loan with FIND_IN_SET(%s, snd.serials_sent), which matches whole entries in the comma list. Magento narrows the rows with LIKE, then matches exactly in PHP:
// QueueService, Magento 2 module 1.0.3
private function loanForSerial(string $serial): ?int
{
$connection = $this->resource->getConnection();
$rows = $connection->fetchPairs(
$connection->select()->from($this->resource->getTableName(LoanResource::TABLE), ['loan_id', 'serials'])
->where('status = ?', Loan::STATUS_OUT)
->where('serials LIKE ?', '%' . addcslashes($serial, '%_') . '%')
);
foreach ($rows as $id => $list) {
if (in_array($serial, Loan::splitList((string)$list), true)) {
return (int)$id;
}
}
return null;
}
Without the second step, scanning SN-1 would also hit the loan that holds SN-10. The addcslashes() matters too: an underscore in a serial is a wildcard in LIKE.
On return, WooCommerce sets the booking's end to now, so the unit is free at once, and emails the member. Magento marks the loan returned and refuses to return it twice. Staff can also scan the item on the rental extension's own Return screen: the bridge listens for salesigniter_rental_reservation_returned and closes the loan there too.
What happens next differs. Magento has a setting, When an item comes back, with three choices: nothing (send from the Send screen), put the member at the top of the Send screen with a daily "ready to send" message, or send the next available item automatically, with a daily pass for free slots (cron 15 6 * * *). On WooCommerce a return frees the slot, and the next item goes out on the next send from the admin.
Lost items
Some items never come back. Magento has markLost(): the loan becomes lost, which frees the slot. With Charge lost items on (the default), a replacement charge goes onto the member's next renewal. The amount is the store's Replacement charge for a lost item if set, else the product's catalog price. A one-time membership has no subscription to charge, and the loan notes say so.
WooCommerce 1.0.9 has no lost status for queue loans. A loan there ends with a return.
Members reorder their own queue
The queue is the member's wish list, so the member sets the order. On WooCommerce, My Account → Rental Queue is a jQuery UI sortable list. On drop it posts the new order of ids to admin-ajax.php:
// CustomerQueue, WooCommerce add-on 1.0.9 (trimmed)
public function reorder() {
check_ajax_referer( 'sisubscriptions_queue', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( [ 'message' => __( 'Please log in first', 'sisubscriptions' ) ] );
}
$order = isset( $_POST['order'] ) ? array_map( 'intval', (array) $_POST['order'] ) : [];
// ... an empty list is refused
$this->rentalQueue->updateQueueOrder( get_current_user_id(), $order );
wp_send_json_success( [ 'message' => __( 'Queue order saved', 'sisubscriptions' ) ] );
}
updateQueueOrder() rewrites the priorities as 1 to n, and every UPDATE has user_id in its WHERE. A posted id from someone else's queue changes nothing.
Magento's reorder() is stricter about its input. Ids that aren't in the member's queue are dropped. Ids the client left out are put after the listed ones, in their old order, so a stale browser tab can't lose an item. The writes run in one transaction. The storefront controller goes further and refuses the whole request if any id isn't the member's. The Luma theme has drag and drop on top of Top, Up and Down buttons for keyboard and touch users; the Hyvä theme uses the buttons only, as plain forms. A headless storefront uses the mutation:
mutation {
sisubReorderQueue(item_ids: ["31", "28", "30"]) {
items {
id
position
name
}
slots
out
can_send
}
}
Members will notice one more difference. On Magento they can remove an item (a Remove button, or sisubRemoveFromQueue). WooCommerce's My Account queue has no remove button; staff remove items through the REST API (DELETE /wc-sisubscriptions/v1/queue/{id}).
Edge cases the code handles
-
The same product twice. WooCommerce checks before it inserts. Magento has a unique key on (customer, product) and inserts with
INSERT IGNORE. - A deleted product in someone's queue. WooCommerce's availability check returns 0 instead of taking down the Send screen. Magento's pick list skips a product it can't load.
- Gaps after a send. Both renumber the queue to 1 to n, so the next item is always position 1.
- A lapsed subscription. On WooCommerce a membership's slots count only while its subscription is active. Magento also counts a subscription in trial, past due, or set to cancel at the end of the period.
- A refused or failed send. WooCommerce removes the booking it wrote when Regular refuses the hand-over. Magento rolls back the transaction.
- A return scanned twice. Magento answers "… was already returned." WooCommerce looks only at loans still out, so the second scan finds nothing to return.
What we'd copy into the next one
Decide early where "out" lives, and point every loan at a real reservation, as both versions do, so stock reports can't drift from the queue. Check availability for an open-ended loan over a long window, never only for today. And let the server own the order: treat the member's list as a request, keep only their own ids, and fill in anything they left out.
The WooCommerce version is on our rental subscriptions, memberships and queue page, and the screens are in the managing rental queues guide. The Magento side has its own Magento 2 Subscriptions & Memberships page, the Magento rental queue docs and the GraphQL reference for subscriptions, memberships and the queue.
















