Framework-agnostic слой хранения вебхуков для PHP.
- Одна таблица MySQL на каждый источник вебхуков.
- Единая схема, отличается только имя таблицы.
- Жизненный цикл записи:
pending → processing → done | failed. - Идемпотентность по
external_event_id. - Подключается в любой фреймворк через DI. Никаких зависимостей от Yii/Laravel/Symfony.
Что модуль делает: сохраняет вебхук, читает запись по id, обновляет её статус. Что модуль не делает: парсинг payload, проверка подписи, очереди, бизнес-логика, HTTP-роутинг.
composer require evgenmil/webhook-storageТребования: PHP ^8.1, ext-pdo, ext-json.
- Источник — строковый slug (
amocrm,bitrix24, ...). Задаёт приложение. - Таблица — своя на каждый источник, имя задаёт приложение через
SourceTableMap. - Схема таблицы — берётся из
WebhookSchema, единая для всех источников.
Приложение отвечает за:
- создание таблицы (миграция своего фреймворка, SQL берётся из
WebhookSchema); - вычисление
external_event_id(id события от вендора или хэш тела); - перевод статуса (
markProcessing/markDone/markFailed).
SQL живёт в пакете, чтобы не было копипасты. Миграция приложения — это одна строка.
use evgenmil\WebhookStorage\Schema\WebhookSchema;
class m260528_120000_create_webhooks_amocrm extends \yii\db\Migration
{
public function safeUp(): void
{
$this->execute(WebhookSchema::createTableSql('webhooks_amocrm'));
}
public function safeDown(): void
{
$this->execute(WebhookSchema::dropTableSql('webhooks_amocrm'));
}
}Новый источник = новая миграция, в которой меняется только имя таблицы. То же самое в Phinx / Doctrine Migrations / Laravel — везде сводится к execute(WebhookSchema::createTableSql($table)).
use evgenmil\WebhookStorage\WebhookStore;
use evgenmil\WebhookStorage\SourceTableMap;
use evgenmil\WebhookStorage\Repository\PdoMysqlWebhookRepository;
$pdo = new \PDO(
'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4',
$user,
$password,
[
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
]
);
$store = new WebhookStore(
new PdoMysqlWebhookRepository($pdo),
new SourceTableMap([
'amocrm' => 'webhooks_amocrm',
'bitrix24' => 'webhooks_bitrix24',
]),
);В контейнере (любой PSR-11 DI) регистрируется WebhookStore синглтоном — всё.
// 1. На приёме вебхука:
$result = $store->save(
source: 'amocrm',
externalEventId: $eventId, // приложение само его извлекает или хэширует
payload: $payload,
);
// $result->id — id записи в таблице webhooks_amocrm
// $result->isDuplicate — true, если такой event_id уже был
// 2. Отдаёте $result->id в очередь / воркер.
// 3. В воркере:
$webhook = $store->get('amocrm', $id);
if ($webhook === null) {
// запись удалена или неверный id — решение за приложением
return;
}
$store->markProcessing('amocrm', $id);
try {
// ... ваша бизнес-логика на $webhook->payload ...
$store->markDone('amocrm', $id);
} catch (\Throwable $e) {
$store->markFailed('amocrm', $id, $e->getMessage());
}WebhookStore::save(string $source, string $externalEventId, array $payload): SaveResult
WebhookStore::get(string $source, int $id): ?WebhookRecord
WebhookStore::markProcessing(string $source, int $id): void // attempts++
WebhookStore::markDone(string $source, int $id): void // last_error = NULL
WebhookStore::markFailed(string $source, int $id, string $error): void
SaveResult { public int $id; public bool $isDuplicate; }
WebhookRecord {
public int $id;
public string $externalEventId;
public array $payload; // уже декодирован из JSON
public Status $status;
public int $attempts;
public ?string $lastError;
public \DateTimeImmutable $receivedAt;
public \DateTimeImmutable $updatedAt;
}
enum Status: string { Pending, Processing, Done, Failed }evgenmil\WebhookStorage\Exception\UnknownSourceException—sourceне зарегистрирован вSourceTableMap.evgenmil\WebhookStorage\Exception\WebhookStorageException— базовый класс пакетных ошибок.- Ошибки
\PDOExceptionпробрасываются как есть (кромеSQLSTATE 23000вsave(), который трактуется как дубликат).
composer install
composer test:unit # юнит-тесты, без БД
composer test:integration # интеграционные, требуют MySQL
composer test # всё вместеПокрывают framework-agnostic ядро: WebhookStore, SourceTableMap,
WebhookSchema, TableNameGuard, SaveResult, Status, исключения.
Репозиторий заменяется моком WebhookRepositoryInterface. БД не нужна.
Поднимают реальные CREATE TABLE / INSERT / UPDATE на MySQL. Каждый
тест создаёт уникальную таблицу __wh_test_<random> и дропает её в
tearDown(). Если БД недоступна, эти тесты автоматически пропускаются —
composer test:unit зелёный без MySQL.
Подготовка БД (один раз):
composer db:test:setupСкрипт читает WH_TEST_DSN, подключается к серверу без указания БД
и делает CREATE DATABASE IF NOT EXISTS с utf8mb4 / utf8mb4_unicode_ci.
Безопасно запускать повторно.
Альтернатива — создать БД руками:
CREATE DATABASE webhook_storage_test
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;Параметры подключения берутся из переменных окружения. Приоритет такой:
реальный export в шелле → файл .env в корне пакета (читает
tests/bootstrap.php) → ничего.
| Переменная | Назначение |
|---|---|
WH_TEST_DSN |
PDO DSN, например mysql:host=127.0.0.1;port=3306;dbname=webhook_storage_test;charset=utf8mb4 |
WH_TEST_USER |
пользователь MySQL |
WH_TEST_PASS |
пароль |
Пример .env в корне пакета (файл в .gitignore):
WH_TEST_DSN="mysql:host=127.0.0.1;port=3306;dbname=webhook_storage_test;charset=utf8mb4"
WH_TEST_USER="root"
WH_TEST_PASS=""Полный цикл первой настройки:
composer install
composer db:test:setup
composer testИзолированный прогон, который не оставляет следов на сервере MySQL:
composer test:ci
# = db:test:setup → test → db:test:dropЕсли тест упал — db:test:drop намеренно не выполняется, чтобы можно
было залезть в БД и посмотреть, что не так. Удалить вручную:
composer db:test:dropСкрипт удаления отказывается дропать БД, имя которой не содержит test
и не начинается с __ — защита от случайной подмены DSN.
src/
WebhookStore.php фасад (публичный API)
SaveResult.php DTO результата save
WebhookRecord.php DTO записи из БД
Status.php enum статусов
SourceTableMap.php source -> table
WebhookRepositoryInterface.php контракт хранилища
Repository/
PdoMysqlWebhookRepository.php реализация на \PDO MySQL
Schema/
WebhookSchema.php генератор CREATE/DROP TABLE SQL
Exception/
WebhookStorageException.php
UnknownSourceException.php
Internal/
TableNameGuard.php валидация имени таблицы
tests/
Unit/ юнит-тесты (без БД)
Integration/ интеграционные тесты (нужен MySQL)
phpunit.xml.dist