Two-Sided Marketplace Architecture: The Decisions That Matter
TidyLinker connects homeowners with cleaners. Sounds simple until you start building it. Two-sided marketplaces have a unique property: every architectural decision affects both sides, and getting it
Two-Sided Marketplace Architecture: The Decisions That Matter
TidyLinker connects homeowners with cleaners. Sounds simple until you start building it. Two-sided marketplaces have a unique property: every architectural decision affects both sides, and getting it wrong for either side kills the platform.
Here's what I learned.
One User Table or Two?
The first question everyone asks. Should "cleaners" and "clients" be separate models with separate tables, or one users table with a role?
I went with one table and roles. Here's why.
The same person can be both. A cleaner who books another cleaner for their own house is both a provider and a client. With two tables, you'd need two accounts with two logins. That's a terrible user experience.
class User extends Authenticatable
{
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
public function isProvider(): bool
{
return $this->roles()->where('name', 'provider')->exists();
}
public function isClient(): bool
{
return $this->roles()->where('name', 'client')->exists();
}
// Provider-specific relationship
public function providerProfile(): HasOne
{
return $this->hasOne(ProviderProfile::class);
}
// Client-specific relationship
public function clientProfile(): HasOne
{
return $this->hasOne(ClientProfile::class);
}
}
The trick is separating role-specific data into profile tables. The users table has auth info (email, password, name). The provider_profiles table has provider-specific data (bio, hourly rate, service areas, verification status). The client_profiles table has client-specific data (addresses, preferences).
This gives you one auth system, one login flow, and type-specific data where it belongs.
What I'd change: I'd use a simpler role system. The roles pivot table with BelongsToMany was overkill. A simple enum column or even a boolean is_provider flag would've been sufficient. Most marketplace users are clearly one or the other, and the edge case of being both isn't common enough to justify a full RBAC system.
Payment Flow
This is where marketplaces get complicated. Money flows from client to platform to provider, with the platform taking a cut. The timing and mechanism matter enormously.
Option 1: Platform collects, then pays out. Client pays the platform. Platform holds the money. After the job is done, platform pays the provider minus commission. This is what I used.
Option 2: Direct payment via Stripe Connect. Client pays the provider directly through Stripe Connect. Platform takes a commission via Stripe's application fees. Money never touches the platform's bank account.
Option 3: Escrow. Client's payment is held in escrow until both sides confirm the job is complete.
I went with Option 1 initially and migrated towards Option 2 (Stripe Connect). Here's why:
Option 1 means your platform is a money services business. Depending on your jurisdiction, that might require licensing. It also means you're holding other people's money, which has regulatory and liability implications.
Stripe Connect handles all of this. The payment goes from client to provider via Stripe, your platform takes an application fee, and you never hold funds.
class CreatePaymentIntent
{
public function execute(Booking $booking): PaymentIntent
{
$provider = $booking->provider;
$platformFee = $this->calculatePlatformFee($booking);
return Stripe::paymentIntents()->create([
'amount' => $booking->total_amount,
'currency' => 'gbp',
'payment_method_types' => ['card'],
'application_fee_amount' => $platformFee,
'transfer_data' => [
'destination' => $provider->stripe_connect_id,
],
'metadata' => [
'booking_id' => $booking->id,
],
]);
}
private function calculatePlatformFee(Booking $booking): int
{
// 15% platform commission
return (int) round($booking->total_amount * 0.15);
}
}
What I'd change: start with Stripe Connect from day one. The migration from platform-held funds to Connect was painful. Stripe Connect's onboarding flow for providers is actually pretty good now — they handle identity verification, bank account setup, and tax forms.
Trust and Verification
A marketplace is a trust machine. Clients need to trust that providers are legitimate. Providers need to trust that clients will pay. The platform needs to mediate disputes.
Layers of trust I implemented:
1. Identity verification. Providers submit photo ID. Initially manual review, later automated via Stripe Connect's identity verification (part of their onboarding).
2. Background checks. For a cleaning marketplace, this matters. I integrated with a third-party DBS check provider. The check status lives on the provider profile:
// On ProviderProfile
protected function casts(): array
{
return [
'verification_status' => VerificationStatus::class,
'dbs_check_status' => DbsCheckStatus::class,
'dbs_checked_at' => 'datetime',
];
}
enum VerificationStatus: string
{
case Pending = 'pending';
case Verified = 'verified';
case Rejected = 'rejected';
case Expired = 'expired';
}
3. Reviews. Both sides review each other. The review system is straightforward:
class Review extends Model
{
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewer_id');
}
public function reviewee(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewee_id');
}
public function booking(): BelongsTo
{
return $this->belongsTo(Booking::class);
}
}
Key design decision: reviews are only allowed after a completed booking, and only for a limited window (14 days). This prevents review bombing and ensures reviews are from genuine interactions.
4. Response time tracking. Providers who respond quickly to enquiries rank higher in search. This is a soft trust signal — if someone responds within an hour, they're probably running a real business.
What I'd change: I'd add mandatory portfolio photos from the start. Cleaners with profile photos and photos of their work got 3x more bookings. Should have been a requirement, not optional.
Messaging
Buyers and sellers need to communicate. The question is how much to mediate.
I built a simple messaging system scoped to bookings:
class Message extends Model
{
public function conversation(): BelongsTo
{
return $this->belongsTo(Conversation::class);
}
public function sender(): BelongsTo
{
return $this->belongsTo(User::class, 'sender_id');
}
}
class Conversation extends Model
{
public function booking(): BelongsTo
{
return $this->belongsTo(Booking::class);
}
public function participants(): BelongsToMany
{
return $this->belongsToMany(User::class, 'conversation_participants');
}
}
Messages are tied to bookings, not users. This is deliberate — it prevents providers from soliciting clients directly (bypassing the platform's commission) and gives the platform a dispute resolution trail.
I also filter messages for phone numbers and email addresses:
class SanitiseMessage
{
public function execute(string $content): string
{
// Strip phone numbers
$content = preg_replace('/(\+?[\d\s\-\(\)]{7,})/', '[phone removed]', $content);
// Strip email addresses
$content = preg_replace('/[\w.+-]+@[\w-]+\.[\w.]+/', '[email removed]', $content);
return $content;
}
}
Aggressive? Yes. But marketplace leakage (parties taking the transaction off-platform) is a real revenue problem.
What I'd change: I'd use Laravel Reverb for real-time messaging from the start instead of polling. The initial polling-based approach felt sluggish.
Search and Matching
The client types their postcode and selects a service. The platform needs to show relevant, available providers. This involves:
- Location matching: providers set service areas (postcodes they cover)
- Availability matching: check the availability slot system (see my article on that)
- Service matching: providers list which services they offer
- Ranking: verified providers first, then by rating, then by response time
class FindMatchingProviders
{
public function execute(SearchCriteria $criteria): Collection
{
return User::query()
->whereHas('providerProfile', function ($query) use ($criteria) {
$query->where('is_active', true)
->where('verification_status', VerificationStatus::Verified);
})
->whereHas('serviceAreas', function ($query) use ($criteria) {
$query->where('postcode_prefix', $criteria->postcodeArea);
})
->whereHas('services', function ($query) use ($criteria) {
$query->where('service_type_id', $criteria->serviceTypeId);
})
->with(['providerProfile', 'reviews'])
->get()
->sortByDesc(function ($provider) {
return $provider->providerProfile->average_rating * 10
+ (1 / max($provider->providerProfile->avg_response_hours, 0.1));
});
}
}
What I'd change: invest in proper geospatial search from the start. Postcode-area matching is crude. Laravel has good support for spatial queries with PostGIS, and it would allow radius-based searching which is more natural for users.
The Commission Model
I started with a flat 15% commission on every booking. Simple, easy to understand, easy to implement.
Over time, I learned:
- High-value bookings (regular weekly cleans) should have lower commission — the lifetime value justifies it
- New providers should get a reduced rate for their first few bookings to encourage onboarding
- The commission needs to cover payment processing fees (Stripe takes ~2.9% + 20p), so your effective margin is lower than you think
class CalculateCommission
{
public function execute(Booking $booking): CommissionBreakdown
{
$baseRate = 0.15; // 15%
// Loyalty discount for regular bookings
$totalBookings = $booking->provider->completedBookings()->count();
if ($totalBookings > 50) {
$baseRate = 0.12;
} elseif ($totalBookings > 20) {
$baseRate = 0.13;
}
$grossCommission = (int) round($booking->subtotal * $baseRate);
$stripeFee = (int) round($booking->total_amount * 0.029) + 20; // 2.9% + 20p
return new CommissionBreakdown(
grossCommission: $grossCommission,
stripeFee: $stripeFee,
netCommission: $grossCommission - $stripeFee,
providerPayout: $booking->subtotal - $grossCommission,
);
}
}
The Decisions That Actually Matter
After building TidyLinker, here's what I think actually matters for a marketplace's architecture:
- One user table with role-specific profiles. Don't split your user base into separate models.
- Stripe Connect from day one. Don't hold other people's money.
- Scope messaging to bookings. Prevents platform leakage and aids dispute resolution.
- Make verification a requirement, not an option. Unverified providers create trust issues for the entire platform.
- Build search around location first. A marketplace without good location matching is just a directory.
Everything else — the specific tech stack, the frontend framework, the hosting setup — matters far less than getting these core decisions right. The architecture should serve the marketplace dynamics, not the other way around.
More at stumason.dev.
Get the Friday email
What I shipped this week, what I learned, one useful thing.
No spam. Unsubscribe anytime. Privacy policy.