Привет, Хабр! Одна из ключевых задач performance-маркетинга — понять, какая реклама реально приводит клиентов. Для кликов есть Яндекс.Метрика, но когда одно из целевых действий — звонок, анализировать источники сложнее, а значит и понять какой креатив работает лучше.
Использование динамических виртуальных номеров позволяет сопоставить звонки с конкретным рекламным источником. Для каждого визита система подставляет уникальный номер телефона и фиксирует обращение. Когда клиент звонит, мы связываем его звонок с конкретной рекламой.
В этой статье мы разберём:
Общую схему работы
Как реализовать её на PHP Yii2
Покажем код с архитектурой
Обсудим проблемы и пути развития
Общая схема работы
Пользователь кликает по рекламе и попадает на посадочную страницу с UTM-метками
На странице динамически подставляется номер телефона из пула ваших свободных номеров в МТС Exolve
Визит сохраняется в базе вместе с номером, UTM-метками и параметрами браузера
Номер бронируется за пользователем на определённое время, например, на 1 час
Если в течение этого времени на номер позвонят, МТС Exolve отправит вебхук
Мы фиксируем факт звонка и связываем его с визитом
Если звонка не было — номер освобождается
Архитектура решения
Для удобства поддержки и масштабирования мы разделяем систему на четыре слоя:
Контроллер — принимает запросы от фронтенда, например, сохранение визита
Сервис — содержит бизнес-логику, такую как резервирование номера и фиксация звонка
Репозиторий — работает только с базой данных, не содержит бизнес-логики
Клиент — отдельный класс для взаимодействия с внешним API МТС Exolve
Такой подход позволяет оставлять контроллеры «тонкими», удобно тестировать бизнес-логику, а интеграции с внешними сервисами менять без переделки всего приложения.
Таблицы базы данных
Для демонстрации мы используем три основные сущности:
phone — пул виртуальных номеров;
visit — визиты пользователей, включая номер и UTM-метки;
call — звонки, связанные с визитами.
Создание таблиц и связей
Файл: m250913_135055_create_dynamic_numbers_tables.php
Создаёт таблицы phone, visit и call, добавляет индексы и связи между таблицами.
<?php
use yii\db\Migration;
class m250913_135055_create_dynamic_numbers_tables extends Migration
{
public function safeUp()
{
$this->createTable('{{%phone}}', [
'id' => $this->primaryKey(),
'number' => $this->string(20)->notNull()->unique(),
'status' => $this->string(20)->notNull(),
'created_at' => $this->dateTime()->defaultExpression('NOW()'),
'updated_at' => $this->dateTime()->append('ON UPDATE NOW()'),
]);
$this->createTable('{{%visit}}', [
'id' => $this->primaryKey(),
'phone_number' => $this->string(20)->notNull(),
'utm_source' => $this->string(50),
'utm_campaign' => $this->string(50),
'utm_medium' => $this->string(50),
'ip' => $this->string(45),
'user_agent' => $this->text(),
'created_at' => $this->dateTime()->defaultExpression('NOW()'),
'updated_at' => $this->dateTime()->append('ON UPDATE NOW()'),
]);
$this->createTable('{{%call}}', [
'id' => $this->primaryKey(),
'call_id' => $this->string(50)->notNull()->unique(),
'phone_number' => $this->string(20)->notNull(),
'visit_id' => $this->integer(),
'created_at' => $this->dateTime()->defaultExpression('NOW()'),
'updated_at' => $this->dateTime()->append('ON UPDATE NOW()'),
]);
$this->createIndex('idx-visit-phone_number', '{{%visit}}', 'phone_number');
$this->createIndex('idx-call-phone_number', '{{%call}}', 'phone_number');
$this->addForeignKey('fk-call-visit_id', '{{%call}}', 'visit_id', '{{%visit}}', 'id', 'SET NULL', 'CASCADE');
}
public function safeDown()
{
$this->dropForeignKey('fk-call-visit_id', '{{%call}}');
$this->dropTable('{{%call}}');
$this->dropTable('{{%visit}}');
$this->dropTable('{{%phone}}');
}
}
Репозиторий и сервисы
Здесь мы разберём ключевые части системы, которые позволяют вам управлять данными и логикой сервиса, чтобы всё работало стабильно и прозрачно.
Управление пулом номеров
Файл: app/Repository/PhoneRepository.php Отвечает за управление пулом номеров. Здесь сосредоточены операции поиска первого свободного номера, его бронирования и освобождения. Репозиторий гарантирует, что система не будет работать с «грязными» данными, что гарантирует корректность статусов телефонов.
<?php
namespace app\Repository;
use app\models\Phone;
use RuntimeException;
class PhoneRepository
{
public function add(Phone $model): void
{
if (!$model->getIsNewRecord()) {
throw new RuntimeException('Adding existing model.');
}
if (!$model->insert(false)) {
throw new RuntimeException('Saving error.');
}
}
public function findFree(): ?Phone
{
return Phone::find()
->where(['status' => Phone::STATUS_FREE])
->orderBy(['updated_at' => SORT_ASC]) // самые старые свободные номера первыми
->one();
}
public function markReserved(int $id): void
{
Phone::updateAll(['status' => Phone::STATUS_RESERVED], ['id' => $id]);
}
public function markFree(string $number): void
{
Phone::updateAll(['status' => Phone::STATUS_FREE], ['number' => $number]);
}
}
Бизнес-логика резервирования номеров
Файл: app/Service/PhoneService.php Добавляет к операциям над номерами бизнес-смысл. Если PhoneRepository просто ищет телефон, то сервис сразу же переводит его в статус «занят». Это исключает ситуации, когда один и тот же номер случайно закрепится за двумя пользователями.
<?php
namespace app\Service;
use app\Repository\PhoneRepository;
use Yii;
use yii\db\Exception;
class PhoneService
{
public function __construct(private PhoneRepository $phoneRepository)
{
}
/**
* @throws Exception
*/
public function reserve(): ?string
{
$transaction = Yii::$app->db->beginTransaction();
try {
$phone = $this->phoneRepository->findFree();
if (!$phone) {
$transaction->rollBack();
return null;
}
$this->phoneRepository->markReserved($phone->id);
$transaction->commit();
return $phone->number;
} catch (Exception $e) {
$transaction->rollBack();
throw $e;
}
}
public function release(string $number): void
{
$this->phoneRepository->markFree($number);
}
}
Хранение визитов и поиск по номеру
Файл: app/Repository/VisitRepository.php Репозиторий для работы с визитами. Он сохраняет визиты пользователей вместе с их атрибутами: IP-адрес, UTM-метки, браузер, телефон. Кроме того, именно он умеет находить визит по номеру телефона, что становится ключевым при обработке звонка.
<?php
namespace app\Repository;
use app\models\Visit;
use RuntimeException;
class VisitRepository
{
public function add(Visit $model): void
{
if (!$model->getIsNewRecord()) {
throw new RuntimeException('Adding existing model.');
}
if (!$model->insert(false)) {
throw new RuntimeException('Saving error.');
}
}
public function findByPhoneWithinHour(string $number): ?Visit
{
$threshold = date('Y-m-d H:i:s', time() - 3600);
return Visit::find()
->where(['phone_number' => $number])
->andWhere(['>=', 'created_at', $threshold])
->one();
}
}
Проверка данных визита
Файл: app/Forms/VisitForm.php Обеспечивает проверку входных данных при создании визита: IP, UTM-меток и браузера. Таким образом бизнес-логика получает уже гарантированно корректные данные, а вся валидация сосредоточена в одном месте.
<?php
namespace app\Forms;
use app\models\Visit;
use yii\base\Model;
class VisitForm extends Model
{
public $utm_source;
public $utm_medium;
public $utm_campaign;
public $ip;
public $user_agent;
public function __construct(Visit $visit = null, $config = [])
{
if ($visit) {
$this->utm_source = $visit->utm_source;
$this->utm_medium = $visit->utm_medium;
$this->utm_campaign = $visit->utm_campaign;
$this->ip = $visit->ip;
$this->user_agent = $visit->user_agent;
}
parent::__construct($config);
}
public function rules()
{
return [
[['ip'], 'ip'],
[['utm_source', 'utm_medium', 'utm_campaign', 'user_agent'], 'string'],
];
}
}
Создание визита и бронирование номера
Файл: app/Service/VisitService.php Контролирует процесс создания визита. Он принимает входные данные от контроллера (IP, UTM, браузер), запрашивает номер у PhoneService, а затем сохраняет визит через VisitRepository.
<?php
namespace app\Service;
use app\forms\VisitForm;
use app\models\Visit;
use app\Repository\VisitRepository;
class VisitService
{
public function __construct(
private VisitRepository $visitRepository,
private PhoneService $phoneService
) {}
public function create(VisitForm $form): Visit
{
$phone = $this->phoneService->reserve();
if (!$phone) throw new \DomainException('Нет свободных номеров');
$entity = Visit::create(
$phone,
$form->utm_source,
$form->utm_medium,
$form->utm_campaign,
$form->ip,
$form->user_agent,
);
$this->visitRepository->add($entity);
return $entity;
}
}
Обработка запроса на создание визита
Файл: app/controllers/VisitController.php Здесь происходит приём AJAX-запросов с сайта. Контроллер проверяет входящие данные через форму VisitForm, передаёт их в VisitService и возвращает результат в формате JSON. Это может быть как выданный номер телефона, так и ошибка в случае некорректных данных. Контроллер выполняет только функцию «получил → проверил → передал дальше → отдал ответ».
<?php
namespace app\controllers;
use Yii;
use yii\web\Controller;
use app\Service\VisitService;
use app\Forms\VisitForm;
use yii\web\Response;
class VisitController extends Controller
{
public $enableCsrfValidation = false;
public function __construct(
$id,
$module,
private VisitService $service,
$config = []
) {
parent::__construct($id, $module, $config);
}
public function actionCreate()
{
Yii::$app->response->format = Response::FORMAT_JSON;
$request = json_decode(Yii::$app->request->getRawBody(), true);
$form = new VisitForm();
if ($form->load($request) && $form->validate()) {
try {
$form->ip = Yii::$app->request->userIP;
$visit = $this->service->create($form);
return $this->asJson(['phone' => $visit->phone_number]);
} catch (\DomainException $e) {
Yii::$app->errorHandler->logException($e);
return $this->asJson(['error' => $e->getMessage()]);
}
}
return $this->asJson(['error' => 'Invalid data']);
}
}
Хранение данных о звонках
Файл: app/Repository/CallRepository.php Хранит факты звонков. Его задача — сохранять новые звонки и следить за уникальностью call_id. Благодаря этому система защищена от дублирующихся данных, например, при повторных уведомлениях от телефонии.
<?php
namespace app\Repository;
use app\models\Call;
use RuntimeException;
class CallRepository
{
public function add(Call $model): void
{
if (!$model->getIsNewRecord()) {
throw new RuntimeException('Adding existing model.');
}
if (!$model->insert(false)) {
throw new RuntimeException('Saving error.');
}
}
public function findByCallId(string $callId): bool
{
return Call::find()
->where(['call_id' => $callId])
->exists();
}
}
Привязка звонка к визиту
Файл: app/Service/CallService.php Обрабатывает звонки. Он получает данные от телефонии через ExolveClient, определяет номер, по которому был звонок, и находит соответствующий визит через VisitRepository. После этого фиксирует звонок в базе с помощью CallRepository. Таким образом звонок становится частью атрибуции и связывается с конкретным визитом.
<?php
namespace app\Service;
use app\Client\ExolveClient;
use app\models\Call;
use app\Repository\CallRepository;
use app\Repository\VisitRepository;
use Yii;
/**
* Сохраняет факт звонка и связывает его с визитом.
*
* Метод handleCall вызывается при завершении звонка (например, через webhook от системы телефонии).
*/
class CallService
{
public function __construct(
private ExolveClient $exolveClient,
private CallRepository $callRepository,
private VisitRepository $visitRepository,
private PhoneService $phoneService,
) {}
public function handle(string $callId): bool
{
if ($this->callRepository->findByCallId($callId)) {
return false;
}
$data = $this->exolveClient->getInfo($callId);
$number = $data['to'] ?? null;
if (!$number || !is_string($number)) {
return false;
}
$visit = $this->visitRepository->findByPhoneWithinHour($number);
$entity = Call::create(
$callId,
$number,
$visit?->id,
);
$db = Yii::$app->db;
return $db->transaction(function () use ($entity, $number) {
$this->callRepository->add($entity);
$this->phoneService->release($number);
return true;
});
}
}
Интеграция с МТС Exolve
Файл: app/Client/ExolveClient.php Инкапсулирует работу с API телефонии. Основной метод — GetInfo, который используется для получения информации о звонке. Этот класс отправляет запросы к API, получает данные о звонках, проверяет корректность ответа и возвращает результат в удобном формате для сервисов.
<?php
namespace app\Client;
use Yii;
use yii\httpclient\Client;
use Exception;
class ExolveClient
{
private const ENDPOINT = "https://api.exolve.ru";
private Client $httpClient;
private string $apiKey;
public function __construct()
{
$this->httpClient = new Client(['baseUrl' => self::ENDPOINT]);
$this->apiKey = Yii::$app->params['exolve']['apiKey'] ?? '';
}
/**
* Получение информации о звонке по call_id
*
* @param string $callId
* @return ?array
* @throws Exception
*/
public function getInfo(string $callId): ?array
{
try {
$response = $this->httpClient->post(
'/statistics/call-history/v2/GetInfo',
['call_id' => [$callId]]
)
->addHeaders(['Authorization' => "Bearer {$this->apiKey}"])
->setFormat(Client::FORMAT_JSON)
->send();
if (!$response->isOk) {
\Yii::error("Ошибка Exolve API: {$response->content}", __METHOD__);
return null;
}
return $response->data ?? null;
} catch (\Throwable $e) {
\Yii::error("Сбой при обращении к Exolve: {$e->getMessage()}", __METHOD__);
return null;
}
}
}
Клиентская часть на JavaScript
Для корректной атрибуции звонка к конкретному визиту и рекламной кампании этот код собирает данные визита пользователя на сайте — такие как UTM-метки и браузер — и автоматически подставляет нужный номер телефона на страницу.
function getUTM() {
const params = new URLSearchParams(window.location.search);
return {
utm_source: params.get('utm_source'),
utm_medium: params.get('utm_medium'),
utm_campaign: params.get('utm_campaign')
};
}
fetch('/visit/create', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
VisitForm: {
user_agent: navigator.userAgent,
...getUTM()
}
})
}).then(r => r.json()).then(data => {
if (data.phone) {
document.getElementById('phone').innerText = data.phone;
}
if (data.error) {
console.error('Ошибка при создании визита:', data.error);
}
});
Заключение
Система динамических номеров позволяет точно сопоставлять звонки с рекламными источниками. Номера подставляются автоматически, визиты фиксируются без ошибок, а бронирование номера на некоторое время за посетителем гарантирует корректную связь звонков с визитами.
Важно учесть нюанс — после окончания периода бронирования номер освобождается и может быть назначен другому пользователю. Чтобы уменьшить риск пересечений, имеет смысл расширять пул номеров или увеличивать время их удержания за посетителем.
В этом примере показаны только ключевые компоненты и общая архитектура системы. В кодовой базе нет моделей Phone, Visit и Call, а также логики освобождения номеров при отсутствии звонка. Для полноценного развёртывания потребуется установка Yii2, настройка зависимостей и разработка этих модулей. Если вам интересно собрать полную систему, напишите в комментариях, и в следующем материале разберём реализацию более детально.
Идеи для развития
Добавить расширенные параметры браузера и географию.
Строить отчёты по кампаниям в Telegram или Grafana.
При атрибуции учитывать повторные визиты.
Интегрировать с CRM.
Получать пул номеров автоматически из МТС Exolve.
Автоматически докупать виртуальные номера при большом трафике.
При звонке возвращать конверсию в рекламную систему ВК или Яндекс Директ.
mSnus
Простите, код ужасен. Навайбкодили или само вышло?
Katner Автор
Здравствуйте. А какие замечания к коду у вас есть?
mSnus
1) Yii
2) очень много общих моментов, которые выдают непонимание того, что такое транзакции в БД, исключения, как работать с датами и т.д.