DEV Community

SIKOUTRIS
SIKOUTRIS

Posted on

Engineering a Multi-Vendor Quote Platform: Real-Time Matching for Home Renovation

Home renovation is a fragmented market. Homeowners struggle to find reliable contractors, and contractors struggle to find qualified leads. We built Mes Devis Travaux ("My Renovation Quotes" in French) to bridge this gap with a quote request platform that matches projects to the right professionals.

Here is the technical architecture behind a platform that handles hundreds of quote requests daily.

The Matching Problem

When a homeowner submits a renovation project, they want quotes from contractors who:

  1. Cover their geographic area
  2. Specialize in the type of work needed
  3. Are available within their timeline
  4. Have relevant certifications (especially RGE for energy renovation grants)

Simple keyword matching does not cut it. A bathroom renovation requires a plumber AND a tiler AND possibly an electrician. We needed a multi-trade matching engine.

The Contractor Profile Schema

CREATE TABLE contractors (
    id INT PRIMARY KEY AUTO_INCREMENT,
    company_name VARCHAR(300),
    siret CHAR(14),
    primary_trade VARCHAR(100),
    secondary_trades JSON,
    service_radius_km INT DEFAULT 30,
    lat DECIMAL(10,8),
    lng DECIMAL(11,8),
    certifications JSON,
    max_concurrent_projects INT DEFAULT 5,
    active_projects INT DEFAULT 0,
    response_rate DECIMAL(3,2),
    avg_response_time_hours INT,
    subscription_tier ENUM("free", "pro", "premium")
);

CREATE TABLE project_requests (
    id INT PRIMARY KEY AUTO_INCREMENT,
    project_type VARCHAR(100),
    required_trades JSON,
    description TEXT,
    budget_range VARCHAR(50),
    timeline VARCHAR(50),
    lat DECIMAL(10,8),
    lng DECIMAL(11,8),
    city VARCHAR(100),
    postal_code CHAR(5),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Enter fullscreen mode Exit fullscreen mode

Geographic Matching With Haversine

We match contractors to projects using the Haversine formula for geographic distance:

function haversineDistance($lat1, $lng1, $lat2, $lng2) {
    $earthRadius = 6371; // km
    $dLat = deg2rad($lat2 - $lat1);
    $dLng = deg2rad($lng2 - $lng1);

    $a = sin($dLat/2) * sin($dLat/2) +
         cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
         sin($dLng/2) * sin($dLng/2);
    $c = 2 * atan2(sqrt($a), sqrt(1-$a));

    return $earthRadius * $c;
}

function findMatchingContractors($project) {
    $contractors = fetchContractorsByTrade($project->required_trades);
    $matches = [];

    foreach ($contractors as $contractor) {
        $distance = haversineDistance(
            $project->lat, $project->lng,
            $contractor->lat, $contractor->lng
        );

        if ($distance <= $contractor->service_radius_km) {
            $score = calculateMatchScore($contractor, $project, $distance);
            $matches[] = [
                "contractor" => $contractor,
                "distance_km" => round($distance, 1),
                "match_score" => $score
            ];
        }
    }

    usort($matches, fn($a, $b) => $b["match_score"] <=> $a["match_score"]);
    return array_slice($matches, 0, 5);
}
Enter fullscreen mode Exit fullscreen mode

For performance, we first filter by a bounding box (simple lat/lng range query that can use indexes) before computing exact Haversine distances on the filtered set.

The Scoring Algorithm

Not all matches are equal. We rank contractors based on multiple factors:

function calculateMatchScore($contractor, $project, $distance) {
    $score = 0;

    // Proximity (closer = better, max 30 points)
    $maxRadius = $contractor->service_radius_km;
    $score += 30 * (1 - ($distance / $maxRadius));

    // Trade match (exact match vs. secondary, max 25 points)
    foreach ($project->required_trades as $trade) {
        if ($contractor->primary_trade === $trade) {
            $score += 25;
        } elseif (in_array($trade, $contractor->secondary_trades)) {
            $score += 15;
        }
    }

    // Responsiveness (max 20 points)
    $score += 20 * $contractor->response_rate;

    // Availability (max 15 points)
    $utilization = $contractor->active_projects / $contractor->max_concurrent_projects;
    $score += 15 * (1 - $utilization);

    // Certifications bonus (max 10 points)
    if ($project->requires_rge && in_array("RGE", $contractor->certifications)) {
        $score += 10;
    }

    return round($score, 1);
}
Enter fullscreen mode Exit fullscreen mode

Contractors never see their score directly. They just see leads ranked by relevance to their profile.

Notification Pipeline

When a new project matches a contractor, they need to be notified immediately — speed matters in the quote business. We use a multi-channel notification system:

class NotificationPipeline {
    public function notify($contractor, $project, $matchScore) {
        // Always send email
        $this->sendEmail($contractor, $project);

        // SMS for high-match scores (saves SMS costs)
        if ($matchScore > 70) {
            $this->sendSMS($contractor, $project);
        }

        // Track notification for analytics
        $this->logNotification($contractor->id, $project->id, $matchScore);
    }

    private function sendEmail($contractor, $project) {
        $subject = "Nouveau projet {$project->type} a {$project->city}";
        $body = $this->renderEmailTemplate("new_lead", [
            "project" => $project,
            "distance" => $this->formatDistance($contractor, $project),
            "action_url" => $this->generateResponseUrl($contractor, $project)
        ]);
        mail($contractor->email, $subject, $body, $this->getHeaders());
    }
}
Enter fullscreen mode Exit fullscreen mode

Fraud Prevention

Quote platforms attract two types of abuse:

  1. Fake project requests: Competitors or bots submitting fake leads to waste contractors time
  2. Fake contractor profiles: People listing services they cannot deliver

Our countermeasures:

function validateProjectRequest($request) {
    $flags = [];

    // Check submission velocity (same IP)
    $recentFromIP = countRecentSubmissions($request->ip, hours: 24);
    if ($recentFromIP > 3) {
        $flags[] = "high_velocity";
    }

    // Phone number validation (French format)
    if (!preg_match("/^(0[1-9])(\d{2}){4}$/", $request->phone)) {
        $flags[] = "invalid_phone";
    }

    // Description quality check
    if (str_word_count($request->description) < 10) {
        $flags[] = "thin_description";
    }

    // Honeypot field check
    if (!empty($request->website_url)) {  // Hidden field
        $flags[] = "honeypot_triggered";
    }

    return [
        "approved" => count($flags) === 0,
        "flags" => $flags,
        "needs_review" => count($flags) === 1  // One flag = manual review
    ];
}
Enter fullscreen mode Exit fullscreen mode

Performance Optimization

The matching query runs on every new project submission. With thousands of contractors, it needs to be fast:

  1. Spatial indexing: We added a SPATIAL INDEX on contractor coordinates and pre-compute bounding boxes
  2. Trade caching: Contractors grouped by trade are cached in APCu, invalidated on profile updates
  3. Async matching: For complex multi-trade projects, matching runs asynchronously and results are emailed within 2 minutes

Average matching time: 150ms for single-trade projects, 400ms for multi-trade.

Metrics That Matter

We track three key platform health metrics:

  • Match-to-response rate: What percentage of matched contractors actually respond? Target: > 40%
  • Response time: How quickly do contractors respond? Target: < 4 hours
  • Quote-to-conversion rate: How many quotes lead to accepted projects? Target: > 15%

Contractors with consistently low response rates get deprioritized in matching. This creates a positive feedback loop — responsive contractors get more leads, which keeps them engaged.


Get free renovation quotes from verified contractors at mes-devis-travaux.fr

Top comments (0)