Back to Projects
Live Project
2026
Solo Project
Full-Stack

Real-Time Call Center Platform for BPO Operations

A multi-tenant contact-center platform with browser-based calling on a self-hosted Asterisk engine — click-to-dial, inbound routing with a hold-music waiting room, transfers, three-way conferencing, hold, call recording with audited playback, agent presence and break tracking, live floor boards, call export, and an agent productivity report. Now deployed on its own server and carrying real calls over a carrier line. Built solo for a BPO, backed by 831 automated tests.

Real-Time Call Center Platform for BPO Operations

About the Project

A contact-center platform built for a BPO that runs calling operations for multiple client businesses at once. Each client is a tenant with its own campaigns, leads, dispositions, scripts, do-not-call list, phone number, and call history — isolated from every other client at the database level. Agents work entirely in the browser: a built-in WebRTC softphone registers against a self-hosted Asterisk switch, so an agent clicks a lead to dial, takes inbound calls routed to the first free agent, parks a caller on hold with music, hands a caller to a colleague, or pulls a third person into a live three-way conference — with the whole call captured as one stereo recording no matter how many hands it passes through. A caller who arrives when every agent is busy waits in a music-filled queue instead of being hung up on, and their record opens on the agent's screen as the phone starts ringing. Around the calls sits the operations layer: agent presence with break types and limits, a live floor board carrying the longest current wait, a per-agent day drill-down for coaching, a twenty-column agent productivity report with occupancy and average handle time, reporting dashboards, a full call export that survives a spreadsheet's habit of mangling phone numbers, queued CSV/XLSX lead import, and an append-only audit log that records every playback of a call recording. Every screen cuts its day and prints its times on the client's own clock, while the database keeps one universal time underneath. The voice engine is not a rented calling API: it is Asterisk 20, self-hosted and driven by the Laravel application over ARI (Asterisk's control interface) through a telephony layer I designed and built. The platform now runs on its own server at hcmis.coderootz.in with a carrier line attached — outbound calls reach real mobiles, inbound calls arrive from the carrier, route to a free agent, connect, and record. Before that line existed I built the entire phone network in Docker on a laptop and proved every call flow against it, so the carrier's arrival was configuration rather than construction. The codebase is held to 831 automated tests with over 3,100 assertions, and nothing counts as done until it has been seen and heard working on the real system.

Problem Statement

BPO call centers typically rent their dialer — hosted per-seat suites where the lead data, the recordings, and the monthly bill all sit with a vendor. This BPO wanted its own platform: multiple client businesses' calling operations on one system, hard guarantees that one client's leads and recordings can never appear in another's view, agents working in the browser with no desk phones, team leaders seeing the floor in real time, and the compliance surface — do-not-call lists, audit trails, recording review — built in rather than bolted on.

My Solution

I architected and built the platform solo: a multi-tenant Laravel application with row-level isolation enforced twice — application context that refuses to run tenant queries without a client set, and PostgreSQL row-level security policies forced at the database, so even raw queries return nothing without tenant context. On top of that foundation sits the telephony layer: a provider interface wrapping Asterisk 20 over ARI, a long-running listener process that reacts to call events, and a WebRTC softphone living in the agent's browser screen. Call flows were built and lab-verified one at a time: outbound click-to-call that rings the logged-in agent's own browser, inbound calls routed to the first free agent with reserve-at-ring so two calls never grab the same person, transfers, and three-way conferencing — with one merged stereo recording per call surviving every handoff. The operations surfaces followed the same pattern: an agent console with presence and break tracking, a live floor board, per-agent drill-downs, a productivity report, and a call export — each verified live before moving on. The platform has since left the laptop: it runs on its own server behind a real carrier line, and every module is re-proven there, because a container quietly does setup work a packaged install does not.

Challenges & Learnings

Driving a Phone Switch from a Web App

There is no SDK that turns Laravel into a call-center engine. I designed a telephony layer around Asterisk's ARI control interface: a provider interface the app talks to ('dial this lead', 'record', 'add to bridge') and a long-running listener that reacts to Asterisk's events and tracks every active call's state. The browser registers as a softphone over WebRTC, so the agent's tab is the phone.

One Recording Per Call, However Many Hands It Passes Through

A call that starts with one agent, gets transferred to another, or grows into a three-way conference must still end as a single recording. Recording attaches to the call, not the agent, and rides through every bridge change — a race between call teardown and the recording merge was caught in testing and fixed. Playback is as controlled as capture: every listen writes one audit row, and agents can play only their own calls.

Client Isolation a Client Could Audit

Each BPO client's data is walled off twice. The application refuses to touch tenant-owned tables without an explicit client context — it throws an exception rather than quietly returning everything. Beneath that, PostgreSQL row-level security is forced on every tenant table with default-deny policies, so even a raw database query outside the application returns zero rows. Cross-client access exists only through one named, audited path.

Building Voice Months Before the Phone Line

A real carrier line (SIP trunk) takes 4–8 weeks to provision. Instead of waiting, I built a complete phone network on a laptop: Asterisk 20 in Docker, softphones and browser tabs as the callers, the Laravel app driving it over ARI. Every call flow — outbound, inbound routing, transfer, conference, recording — was proven end-to-end with real audio before the line existed. The bet paid off when the line landed: the first real outbound call rang a mobile, recorded both halves, and played back in the browser on the day the trunk was configured, and inbound followed the next day.

Agent State That Tells the Truth

Floor management lives or dies on whether 'available' actually means available. Presence runs on a heartbeat from the agent console; dead sessions are closed lazily at read time rather than by a background job, so the live board and the reports can never disagree with each other. Break handling was researched against Amazon Connect and Genesys and matched: a returning agent resumes a still-fresh break in place, while a stale session stays dead.

The Lab Was Not the Server

Every call flow worked in the Docker lab and then broke on the real machine — three times, for three unrelated reasons. Ubuntu's Asterisk package loads a phone module deprecated years ago that claims the same WebSocket name as the current one, so agent logins were rejected as 'wrong password' while the password was correct. The package creates seven spool folders and not the one recording writes into, so calls connected and dropped the instant recording started. And the packaged build is an older release than the container's, missing a channel field the inbound path read — which surfaced as 'no free agent available' rather than as a missing field. The rule I keep: a container quietly does setup work a packaged install does not, so 'it worked in the lab' is evidence about the lab and nothing else.

One Day, Two Different Answers

The database stores every moment in universal time, which is correct, and the screens printed them that way, which was not. On an Indian floor that moves the start of the day by five and a half hours, so one chosen date named one set of calls in the exported file and a different set on screen. The fix put the client's own timezone behind a single method everything reads, and converted the day's boundaries once inside the shared filter rather than at each call site, where one caller can forget. Tracing found three screens the obvious fix never reached because they each built their own 'today'. It also found the mirror-image error: applying the panel-wide setting to the business-hours pickers shifted 'opens at 09:30' to 04:00, because an opening time is a wall-clock time already written on the client's clock, not a moment in time. The existing tenant tests caught that one before it shipped.

Technology Stack

Laravel

The application core: tenancy, RBAC, queued lead imports, audit logging, and the long-running listener process that drives the phone switch.

FilamentPHP + Livewire

Admin panel and the real-time operational screens — agent console, live floor board, dashboards — without building and maintaining a separate frontend app.

PostgreSQL

Row-level security forced on every tenant-owned table with default-deny policies, so client isolation is enforced by the database itself, not just the application.

Asterisk 20 + ARI

Self-hosted open-source phone switch instead of a per-minute calling API — the app controls dialing, bridging, recording, and transfers over ARI, keeping call data and cost in-house.

WebRTC (JsSIP)

Agents call from the browser tab itself — no desk phones, no installed softphones, nothing to provision on the agent's machine beyond a login.

Docker

Asterisk runs containerized, and the same containerization powered a full local phone lab that let the voice path be built and proven before the carrier line existed.

Pest

831 automated tests and over 3,100 assertions across tenancy isolation, call flows, presence arithmetic, timezone boundaries, and access gates — the safety net that lets a solo developer move fast on telephony.

SIP trunking

The seam between the platform and the outside phone network. The switch talks to a carrier trunk rather than to any one vendor's API, so the carrier is a configuration choice the platform can change without touching call logic.

Technologies Used

LaravelFilamentPHPLivewirePostgreSQLAsterisk 20ARIWebRTCSIP TrunkingDocker