DEV Community

Vinayak Clouds
Vinayak Clouds

Posted on

How We Build Custom CRMs for Indian SMBs: Architecture, Stack and Lessons Learned

If you build business software in India for long enough, you get asked to build a CRM. The brief usually sounds the same: leads come in from the website, IndiaMART, Justdial, WhatsApp, phone calls and walk-ins. They end up in a notebook, an Excel sheet or someone's phone. Follow-ups slip, two salespeople call the same customer, and when someone leaves, their customer history goes with them.

This post covers how we approach these builds as developers: the data model, the modules that actually matter, the stack, the integrations that cause the most trouble, roles and tenancy, and when you shouldn't build one at all. The code is illustrative, simplified so the ideas are easy to see, and not lifted from any client project.

Start with the question nobody wants to ask: should this be built at all?

Before touching a schema, we write down what the client actually needs. For most small and mid-sized Indian businesses the list looks like this:

  • Lead capture from every source (web forms, marketplaces, Facebook/Instagram lead ads, WhatsApp, calls)
  • Assignment and follow-up reminders
  • A pipeline view of deals by stage
  • Quotes and proforma invoices with GST details
  • WhatsApp and call logging
  • A usable mobile experience for field sales
  • Role-based access
  • Reports: leads by source, conversion by salesperson, forecast

Then comes the honest bit. If an off-the-shelf CRM covers about 80% of that list, and the remaining 20% isn't central to how the business makes money, buy it. If the gaps are real but small, customising a platform (Salesforce, HubSpot, Zoho, or an open-source CRM) is often the middle path. A custom build makes sense when:

  • The sales or service process is genuinely different (dealer networks, site visits, batch tracking, AMC renewals).
  • The CRM has to talk closely to an existing ERP, accounting or production system.
  • The team will grow enough that per-user licence fees become a big recurring cost.

And one non-technical requirement trumps all of these: someone inside the business has to own the system. Without an owner, no CRM gets used, custom or not.

For a 3–5 person sales team we usually suggest starting with a packaged tool, learning what the team really needs, and building only when the gaps clearly cost money.

The data model

Most CRM complexity comes from a handful of tables. Ours usually start with:

  • leads: a person or company that showed interest, plus where they came from
  • contacts / accounts: deduplicated customers once a lead is qualified
  • deals: an opportunity with a value, a stage and an expected close date
  • activities: calls, WhatsApp messages, emails, meetings and notes, polymorphic against leads/deals
  • follow_ups: scheduled tasks with a due time and an assignee
  • quotes / invoices with line items
  • users, teams, roles

Here's a trimmed Laravel migration for leads:

// database/migrations/xxxx_create_leads_table.php (illustrative)
Schema::create('leads', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete();
    $table->foreignId('team_id')->nullable()->constrained()->nullOnDelete();

    $table->string('name');
    $table->string('company')->nullable();
    $table->string('phone_e164', 20)->nullable();   // normalised, e.g. +919812345678
    $table->string('email')->nullable();
    $table->string('city')->nullable();
    $table->char('state_code', 2)->nullable();      // GST state code, used later for tax

    $table->string('source', 32);                   // website, indiamart, justdial, whatsapp, walk_in...
    $table->string('source_ref')->nullable();       // external ID from the marketplace/API
    $table->string('stage', 32)->default('new');
    $table->json('custom_fields')->nullable();      // per-client extras without migrations

    $table->timestamp('last_contacted_at')->nullable();
    $table->timestamps();
    $table->softDeletes();

    $table->index(['tenant_id', 'phone_e164']);
    $table->index(['tenant_id', 'owner_id', 'stage']);
    $table->unique(['tenant_id', 'source', 'source_ref']);
});
Enter fullscreen mode Exit fullscreen mode

A few decisions here come straight from things going wrong:

  • Normalise phone numbers on write. In India the phone number, not the email, is the real identity of a lead. The same customer shows up as 98123 45678, +91-9812345678 and 09812345678. Store E.164 and dedupe on it, or your "two salespeople called the same customer" problem never goes away.
  • Make imports idempotent. Marketplace integrations retry, re-send and overlap. A unique key on (source, source_ref) turns duplicate webhook deliveries into no-ops.
  • Keep a JSON escape hatch. Every client has three fields nobody else needs. A custom_fields column (with a definitions table driving the UI) beats a migration per client.

Pipeline stages as an enum

Stages look like pure configuration, but certain behaviour depends on them: "won" triggers invoicing, "lost" demands a reason. We keep a small fixed set of system stages in code and let clients rename or add display stages on top.

// app/Enums/DealStage.php (illustrative)
enum DealStage: string
{
    case New         = 'new';
    case Contacted   = 'contacted';
    case Qualified   = 'qualified';
    case QuoteSent   = 'quote_sent';
    case Negotiation = 'negotiation';
    case Won         = 'won';
    case Lost        = 'lost';

    public function isClosed(): bool
    {
        return in_array($this, [self::Won, self::Lost], true);
    }

    /** Allowed transitions; keeps reports sane. */
    public function canMoveTo(self $next): bool
    {
        return ! $this->isClosed() || $next === self::Qualified; // allow reopening
    }
}
Enter fullscreen mode Exit fullscreen mode

Every stage change also writes a row to a deal_stage_history table. Pipeline reports ("how long do deals sit in Negotiation?") are impossible to build later if you only store the current stage.

The modules that matter (and the order to ship them)

The biggest lesson from these projects: ship the core first, get adoption, then add modules. Our usual MVP is lead capture, assignment, follow-ups, the pipeline and basic reports. Everything else comes after people are actually using it.

Leads and assignment. Round-robin, by territory or by source. Keep the "add lead" form tiny. If adding a lead takes two minutes, salespeople go back to WhatsApp notes. Name, phone, source and a note is enough. Everything else can be filled in later.

Follow-ups. The feature that pays for the whole project. A scheduled job surfaces overdue follow-ups each morning, and an "untouched leads" report shows anything with no activity after N hours. In Laravel this is a queued job plus the scheduler. Nothing fancy, but it has to be reliable.

WhatsApp and SMS. Covered in the integrations section below, because it deserves its own warnings.

GST quotes and invoicing. Indian clients expect quotes and proforma invoices with GST, not just a deal amount. The core logic is easy to get subtly wrong, so we keep it in one tested service:

// Illustrative only — confirm rules with the client's accountant.
final class GstCalculator
{
    public function split(int $taxablePaise, float $rate, string $supplierState, string $placeOfSupply): array
    {
        $tax = intdiv($taxablePaise * (int) round($rate * 100), 10000);

        return $supplierState === $placeOfSupply
            ? ['cgst' => intdiv($tax, 2), 'sgst' => $tax - intdiv($tax, 2), 'igst' => 0]
            : ['cgst' => 0, 'sgst' => 0, 'igst' => $tax];
    }
}
Enter fullscreen mode Exit fullscreen mode

Practical notes: store money as integer paise (or DECIMAL), never floats. Store the HSN/SAC code and the rate on each line item at the time of invoicing, not just on the product, because rates change. Generate invoice numbers per financial year (April–March) inside a DB transaction with a row lock, so two invoices never get the same number. And if the client already uses accounting software, it's usually better to push quotes and invoices there than to rebuild accounting inside the CRM.

Reports. Leads by source, conversion by salesperson and a simple revenue forecast cover most needs. Build them on the history tables, not on current state.

Stack choices

For web-based CRMs we typically use Laravel or Node.js on the back end, React on the front end, and MySQL or PostgreSQL, hosted on AWS, Azure or Google Cloud. In practice the deciding factor isn't which stack is best but who will maintain this in three years. If the client's in-house developer knows PHP, Laravel wins. If there's an existing Node service estate, we match it.

Why Laravel shows up a lot for us:

  • Policies, queues, the scheduler, notifications and migrations cover most CRM plumbing out of the box.
  • Eloquent global scopes make tenant isolation hard to forget (more below).
  • It's easy to hire for in India, which matters for long-term maintenance.

On the front end, React handles the interactive parts (a drag-and-drop pipeline board, inline editing on lead lists, fast filters). The non-negotiable is mobile. Field sales teams live on their phones. If the CRM isn't fast and usable on a mid-range Android phone on a patchy connection, it won't get used. That means a responsive web app at minimum, a PWA or native app when field teams need it, and click-to-call / click-to-WhatsApp buttons that log an activity automatically.

Integrations: where the time actually goes

Marketplace leads (IndiaMART, Justdial, TradeIndia). These can often be pulled in automatically, through the marketplace's lead APIs or by parsing lead-notification emails, depending on what each platform offers for that account. We confirm what's available during discovery rather than promising it up front. Either way, treat it as an unreliable feed: poll or receive, normalise, dedupe on source_ref and phone, then assign.

Website and ad forms. A signed webhook endpoint that writes to a queue, not straight to the leads table. Facebook/Instagram lead ads follow the same pattern.

WhatsApp. Many clients want WhatsApp "inside the CRM". The reliable route is the WhatsApp Business Platform (API) through an official provider. It supports template messages, two-way conversations logged against the lead, and automated reminders. Unofficial tools that automate a personal WhatsApp account risk getting the number banned, and we don't build on them. Two engineering implications:

  1. Outbound messages outside the customer-service window need pre-approved templates, so model templates as first-class records with their variables.
  2. There are conversation-based charges from Meta and the provider. Budget for them and check current pricing, and log every send so the client can reconcile the bill.

ERP and accounting. This is often the reason the build is custom in the first place. Define clear ownership: which system is the source of truth for customers, products and prices. Then sync in one direction per entity where you can.

Roles and multi-tenancy

The usual access model is simple to describe: salespeople see their own leads, managers see their team's, owners see everything. In Laravel, that's a query scope plus a policy:

// Illustrative
public function scopeVisibleTo(Builder $q, User $user): Builder
{
    return match ($user->role) {
        Role::Owner   => $q,
        Role::Manager => $q->where('team_id', $user->team_id),
        default       => $q->where('owner_id', $user->id),
    };
}
Enter fullscreen mode Exit fullscreen mode

Apply it in list endpoints and exports too. Exports are where data quietly leaks. Add audit logs for exports, deletes and reassignments.

On tenancy: a CRM built for one business can be single-tenant with its own database. That's simpler to reason about, back up and hand over. A CRM you plan to offer to many businesses needs multi-tenancy from day one. We use a tenant_id on every table, enforced by a global scope and covered by tests that try to read across tenants. Retrofitting tenancy later is one of the more painful migrations you can sign up for, so decide early.

Security basics apply regardless: role-based permissions, encrypted connections, regular backups, and audit logs. Also put code and data ownership in writing. Clients should own the source code and their data.

Lessons learned

  • Too many fields kill adoption. Start minimal and add fields only when a report needs them.
  • No owner, no CRM. Someone senior has to check it daily and expect the team to use it.
  • Don't build everything at once. An MVP live in production teaches you more than a perfect spec.
  • Clean the data before migrating it. Importing messy duplicates from Excel makes the new system untrustworthy from day one. Budget real time for dedupe and normalisation.
  • Prototype with the actual salespeople. A clickable prototype of the lead list, lead detail, pipeline and mobile view, tested with the people who'll use it daily, catches problems that workshops with management never surface.
  • Review after 30 days. Usage data shows exactly which screens slow people down.

For scale, custom platforms like this typically take 8–16 weeks depending on scope, with the MVP going live first and modules added in phases.

We've written about the business side of the build-vs-customise-vs-buy decision on our CRM development page. If you've built CRMs for Indian SMBs, I'd like to hear how you handled marketplace dedupe and GST numbering. Leave a comment.


Written by the team at Vinayak Clouds Inc., a web development, eCommerce and custom software company founded in 2010 by Jatin Mevada in Sidhpur, Gujarat.

Top comments (0)