Dokan Multi-Vendor Marketplace Development for a B2B Industrial-Parts Store
A B2B industrial-parts marketplace on Dokan Pro needed commissions that matched product margins and withdrawals that could not go negative. I rebuilt both, plus onboarding, shipping, accounting exports and page speed.
- Client
- B2B industrial-parts marketplace
- My role
- Lead WordPress / WooCommerce engineer
- Dokan Pro
- WooCommerce
- WooCommerce Subscriptions
- WP Rocket
- Redis object cache
- PHP
- active vendors
- 340
- GMV processed per year
- ≈ $4.2M
- withdrawal processing
- 5 days → same day
- archive TTFB
- 4.8s → 1.6s
active vendors
GMV processed per year
withdrawal processing
archive TTFB
The challenge
The client runs a B2B industrial-parts marketplace on Dokan Pro. Vendors fulfil their own orders, and the client also keeps a central warehouse for some SKUs.
Commission was a flat 10% for everyone. That cut margin on low-value, high-volume categories and overpaid the store on high-ticket items.
Vendors could also request withdrawals against pending order balances. When a buyer later charged back, the vendor's balance went negative. Support was reviewing about 5 withdrawals by hand every day.
What I built
- 01
Tiered, category-aware commission engine
Built on Dokan's dokan_get_seller_percentage filter. Each vendor's tier is stored in user meta, and low-margin categories get an override. All the rules live in one filter with a test matrix.
- 02
Withdrawal guard
Only completed-order funds can be withdrawn. A rolling hold period, configurable per vendor tier, and a negative-balance lock replaced the manual review queue.
- 03
Per-vendor shipping zones
Shipping rules by weight and dimensions for each vendor, with a warehouse-fulfilled fallback when a vendor has no zone for the destination.
- 04
Vendor onboarding wizard
KYC document upload, a store-category picker and a required-fields filter, so non-technical sellers could not submit an incomplete store.
- 05
Accounting export
CSV export and a scheduled email with the commission breakdown per order line, the VAT split and payout batch IDs.
- 06
Front-end performance pass
Object caching, removal of a slow meta_query on the shop archive, and lazy-loaded vendor maps.
Gotchas and how I fixed them
Dokan's percentage filter changes between versions
The filter signature shifts from one Dokan release to another. I guard with function_exists and check the Dokan changelog before every release.
Renewal orders paid vendors nothing
Some vendors sold subscriptions. Commission has to be calculated on renewal orders too, not only the parent order. I hook wcs_renewal_order_created and copy the vendor line-item meta from the parent. Without that, vendors are paid once and renewals silently pay 100% to the store.
Withdrawal race condition
Two requests fired in the same second both passed the balance check. A short wp_cache_add lock keyed on the seller ID fixes it, with a database row lock where Redis is not available.
HPOS compatibility
Compatibility with High-Performance Order Storage is declared with FeaturesUtil::declare_compatibility('custom_order_tables', ...).
Code from the build
add_filter( 'dokan_get_seller_percentage', function ( $percentage, $seller_id, $product_id, $category_id ) {
$tier = get_user_meta( $seller_id, '_vendor_tier', true ) ?: 'standard';
$tiers = [ 'standard' => 90, 'silver' => 92, 'gold' => 94 ]; // vendor keeps %
$base = $tiers[ $tier ] ?? 90;
// Low-margin category override
if ( in_array( (int) $category_id, [ 41, 42 ], true ) ) {
$base -= 3;
}
return max( 0, min( 100, $base ) );
}, 10, 4 );Commission is a business rule, not a number — keep it in one filter with a test matrix.
add_filter( 'dokan_withdraw_request_validation_errors', function ( $errors, $seller_id, $amount ) {
$withdrawable = dokan_get_seller_withdrawable_balance( $seller_id ); // completed only
$pending = dokan_get_seller_balance( $seller_id, false ) - $withdrawable;
if ( $amount > $withdrawable ) {
$errors[] = sprintf(
__( 'Only completed-order funds are withdrawable. Pending: %s', 'my-textdomain' ),
wc_price( $pending )
);
}
return $errors;
}, 10, 3 );The hook name varies by Dokan version — verify against the installed version and guard with function_exists.
add_action( 'before_woocommerce_init', function () {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables', __FILE__, true
);
}
} );Skip this and your plugin silently breaks the day the client enables High-Performance Order Storage.
Results
- 340 active vendors and about $4.2M in GMV a year processed through the marketplace.
- Withdrawal processing went from 5 days of manual review to same-day automated payouts, recovering about 30 hours a month of admin time.
- Marketplace archive TTFB dropped from 4.8s to 1.6s, and mobile LCP is under 2.5s.
- Vendor-related support tickets fell about 40% in the first quarter after the onboarding and withdrawal changes.
- Zero negative-balance incidents in 14 months after launch, down from about 6 a month.
Questions people ask
Can Dokan commission be different per vendor or per category?
Yes. On this marketplace I used the dokan_get_seller_percentage filter. Each vendor's tier (standard, silver or gold) is stored in user meta, and low-margin categories get an extra override. All of it sits in one filter, tested against a matrix of tiers and categories.
How do I stop Dokan vendors from withdrawing money that can still be charged back?
Limit withdrawals to completed-order funds, add a rolling hold period per vendor tier, and lock accounts with a negative balance. After I added these, the marketplace had zero negative-balance incidents in 14 months, compared with about 6 a month before.
Does Dokan pay vendors commission on WooCommerce Subscriptions renewals?
Not by default in this setup. Without extra work, vendors were paid on the parent order and renewals paid 100% to the store. Hooking wcs_renewal_order_created and copying the vendor line-item meta from the parent order fixes it.
How much faster can a Dokan marketplace get?
On this build, archive TTFB went from 4.8s to 1.6s. The changes were object caching with Redis, removing a slow meta_query on the shop archive, and lazy-loading vendor maps. Mobile LCP is now under 2.5s.