Привет, Хабр! Одна из ключевых задач performance-маркетинга — понять, какая реклама реально приводит клиентов. Для кликов есть Яндекс.Метрика, но когда одно из целевых действий — звонок, анализировать источники сложнее, а значит и понять какой креатив работает лучше.

Использование динамических виртуальных номеров позволяет сопоставить звонки с конкретным рекламным источником. Для каждого визита система подставляет уникальный номер телефона и фиксирует обращение. Когда клиент звонит, мы связываем его звонок с конкретной рекламой.

В этой статье мы разберём:

  • Общую схему работы

  • Как реализовать её на PHP Yii2

  • Покажем код с архитектурой

  • Обсудим проблемы и пути развития

Общая схема работы

  1. Пользователь кликает по рекламе и попадает на посадочную страницу с UTM-метками

  2. На странице динамически подставляется номер телефона из пула ваших свободных номеров в МТС Exolve

  3. Визит сохраняется в базе вместе с номером, UTM-метками и параметрами браузера

  4. Номер бронируется за пользователем на определённое время, например, на 1 час

  5. Если в течение этого времени на номер позвонят, МТС Exolve отправит вебхук

  6. Мы фиксируем факт звонка и связываем его с визитом

  7. Если звонка не было — номер освобождается

Архитектура решения

Для удобства поддержки и масштабирования мы разделяем систему на четыре слоя:

  • Контроллер — принимает запросы от фронтенда, например, сохранение визита

  • Сервис — содержит бизнес-логику, такую как резервирование номера и фиксация звонка

  • Репозиторий — работает только с базой данных, не содержит бизнес-логики

  • Клиент — отдельный класс для взаимодействия с внешним 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.

  • Автоматически докупать виртуальные номера при большом трафике.

  • При звонке возвращать конверсию в рекламную систему ВК или Яндекс Директ.

Комментарии (3)


  1. mSnus
    30.09.2025 15:09

    Простите, код ужасен. Навайбкодили или само вышло?


    1. Katner Автор
      30.09.2025 15:09

      Здравствуйте. А какие замечания к коду у вас есть?


      1. mSnus
        30.09.2025 15:09

        1) Yii

        2) очень много общих моментов, которые выдают непонимание того, что такое транзакции в БД, исключения, как работать с датами и т.д.