Привет, Хабр! Одна из ключевых задач 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.
Автоматически докупать виртуальные номера при большом трафике.
При звонке возвращать конверсию в рекламную систему ВК или Яндекс Директ.
