DEV Community

Palks Studio
Palks Studio

Posted on

I Built a Full Recruitment System in PHP — No Database, No SaaS, No Subscription

CANDIDATE_FLOW recruiter dashboard — applications ranked by matching score

🇫🇷 Interface currently in French — English version coming soon.

Automatic candidate scoring, recruiter dashboard, multi-campaign management. Deployed on any standard Apache hosting.

After building a complete invoicing system the same way, I applied the same philosophy to recruitment: no SaaS dependency, no monthly subscription, no data leaving the client's server.

The result is CANDIDATE_SYSTEM — a self-hosted recruitment engine that automatically scores applicants against a job profile, ranks them by matching score, and lets the recruiter focus on the top profiles instead of reading through dozens of CVs.


The Problem With Existing Tools

Most recruitment platforms follow the same model: you pay monthly, your data sits on their servers, and the day you stop paying, everything disappears.

For small companies, DSIs, and independent recruiters, this makes no sense. They don't need a 300€/month SaaS. They need a simple, reliable tool that works on their own hosting.


How It Works

The system is split into two parts:

  • Public — the application form (/candidat/)
  • Private — configuration, scoring engine, candidate data (outside the webroot)

When a candidate submits the form, the scoring engine calculates a final score based on three nested levels:

contribution = scores[answer] × (weight / 100) × (global_weight / 100)
Enter fullscreen mode Exit fullscreen mode

Level 1global_weight: how much a section counts in the final score.

Level 2weight: how much a question counts within its section.

Level 3scores: the raw value assigned to each possible answer (0–100).

Every candidate gets a score on submission. The dashboard displays them ranked from highest to lowest.


The Scoring Engine

The scoring logic lives in a single $coring class. It reads the campaign configuration files — questions with their weights and scores, and the job profile — then runs the calculation across all sections.

public function calculate(array $reponses): array
{
    foreach ($this->questions as $question) {
        $score_brut   = $this->getScoreBrut($question, $reponses[$question['id']]);
        $contribution = $score_brut * ($question['weight'] / 100);
        $detail[$question['section']]['score_section'] += $contribution;
    }

    foreach ($detail as $section_id => &$section) {
        $section['contribution'] = $section['score_section'] * ($section['global_weight'] / 100);
        $score_final += $section['contribution'];
    }

    // Apply penalties
    foreach ($this->profil['malus'] ?? [] as $question_id => $penalties) {
        if (isset($penalties[$reponses[$question_id]])) {
            $malus += $penalties[$reponses[$question_id]];
        }
    }

    return ['score_final' => round(max(0, $score_final + $malus), 1), ...];
}
Enter fullscreen mode Exit fullscreen mode

The tech stack question (checkbox type) is scored differently — each selected technology is matched against the scores defined in the question itself. No hardcoded list, fully configurable from the admin interface.


Multi-Campaign Architecture

Each campaign is completely independent: its own questions, its own job profile, its own candidate data.

campaigns/
└── [slug]/
    ├── config/          → questions, scores, job profile
    └── data/            → applications, uploads, lock file
Enter fullscreen mode Exit fullscreen mode

Creating a new campaign from the dashboard copies the templates, generates the folder structure, and redirects immediately to the new campaign. One click, ready to use.

A campaign.lock file controls whether the form is open or closed. The recruiter can close a campaign (which purges all candidate data and sends a closure email to every applicant), then reopen it later from the dashboard.


The Admin Interface

The recruiter configures everything from the admin panel — no file editing required:

  • Job profile — position title, mission type, location, salary range
  • Questions — edit labels, answers, scores, section weights; add or delete questions
  • Section weights — real-time total indicator (must reach 100%)
  • Penalty rules — attach negative scores to specific answers on specific questions
  • Settings — sender name, email, site URL, dashboard password

The section weight total updates in real time as you type. If it doesn't add up to 100%, the indicator turns red.


No Database — Just JSON Files

Every application is stored as a flat JSON file:

{
    "id": "20260514_143000_abc123",
    "date": "2026-05-14 14:30:00",
    "score_final": 74.5,
    "score_label": "Good fit",
    "score_detail": {
        "terrain": { "score_section": 68.0, "contribution": 40.8 },
        "classic": { "score_section": 75.0, "contribution": 15.0 }
    },
    "reponses": { ... },
    "trigger_fields": { ... }
}
Enter fullscreen mode Exit fullscreen mode

No ORM, no migrations, no connection pooling. The dashboard loads all JSON files from the applications/ folder, sorts them by score_final, and renders the list.

It's fast enough for the volumes this tool is designed for — typically 20 to 200 applications per campaign.


Security

A few things worth noting:

  • CSRF token on the application form — generated per session, validated on submission
  • Strict public/private separation — no config file is accessible via the web
  • Duplicate prevention — one email per campaign, checked on every submission
  • Upload validation — PDF only, 5 MB max, extension and MIME checked
  • HTTP security headers on every page (X-Frame-Options, X-Content-Type-Options, Referrer-Policy)
  • Session required for dashboard, export, application view, and campaign closure

Conditional Form Fields

Some questions trigger additional fields depending on the answer. For example, answering "Yes" to "Do you have a portfolio?" reveals a required URL field. Answering "Yes" to "Any certifications?" triggers a PDF upload.

The trigger configuration lives in the questions config file:

{
    "id": "portfolio",
    "trigger": {
        "value": "Oui",
        "field_id": "portfolio_url",
        "field_type": "url",
        "field_label": "Portfolio URL",
        "required": true
    }
}
Enter fullscreen mode Exit fullscreen mode

On the frontend, a small JS handler watches radio inputs and toggles the conditional field visibility. On the backend, handler.php only validates the conditional field if the triggering answer was given.


What Gets Delivered

When deployed for a client:

  • Full system installed on their hosting
  • First campaign configured with their job profile
  • Admin interface ready to use
  • Technical documentation + user guide
  • Color customization included

No recurring cost. No dependency on external infrastructure. One flat fee.


Stack

  • PHP 8.x — no framework, no Composer
  • Apache + .htaccess — URL rewriting, security headers, directory protection
  • Vanilla JS — form validation, clipboard API, accordion UI
  • JSON flat files — zero database

Links


https://palks-studio.com

Top comments (0)