Питання на співбесіді: Інтеграції зі сторонніми API
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
HTTP-клієнт Laravel (обгортка над Guzzle) робить запити до сторонніх API коротко й зручно:
use Illuminate\Support\Facades\Http;
$response = Http::withToken(config('services.nova_poshta.key'))
->acceptJson()
->connectTimeout(3)
->timeout(10)
->get('https://api.example.com/v1/parcels/123');
$status = $response->json('data.status');
Тайм-аути - обов'язкові для свідомого вибору:
connectTimeout- скільки чекати на встановлення з'єднання (за замовчуванням 10 секунд);timeout- скільки чекати на всю відповідь (за замовчуванням 30 секунд).
Тридцять секунд очікування повільного API в обробнику запиту користувача - це тридцять секунд зайнятого процесу PHP і користувач, що дивиться на спінер. Для запитів у веб-обробнику тайм-аути варто ставити короткими.
Помилки - клієнт не кидає винятки сам. Відповідь 404 чи 500 - це звичайний об'єкт Response, і код спокійно продовжить роботу з порожніми даними. Перевіряйте явно:
if ($response->failed()) { // 4xx або 5xx
// ...
}
$response->successful(); // 2xx
$response->clientError(); // 4xx
$response->serverError(); // 5xx
$data = $response->throw()->json(); // кинути RequestException на 4xx/5xx
Два різні типи збою:
Illuminate\Http\Client\RequestException- сервер відповів з помилкою (throw());Illuminate\Http\Client\ConnectionException- відповіді не було взагалі: тайм-аут, DNS, відмова в з'єднанні. Кидається завжди.
Що ще варто зробити відразу:
- ключі й адреси - у
config/services.phpз.env, не в коді; - базову адресу й автентифікацію зібрати в одному місці (макрос або клас-клієнт), щоб не дублювати по проєкту;
- логувати збої з контекстом (ендпойнт, статус, ідентифікатор запиту), але без токенів і персональних даних;
- не довіряти формату відповіді: сторонній API може змінити поле -
$response->json('data.status')повернеnull, і це треба обробити.
Довгі чи масові виклики - у черзі, а не в запиті користувача.
Тести не повинні ходити в реальні сторонні API: вони стають повільними, нестабільними, залежать від мережі й можуть створювати справжні платежі чи відправляти справжні SMS.
Http::fake() підміняє відповіді:
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
it('stores the tracking status', function () {
Http::fake([
'api.example.com/v1/parcels/*' => Http::response(['data' => ['status' => 'delivered']]),
'api.example.com/*' => Http::response(status: 404),
]);
app(ParcelTracker::class)->refresh($parcel);
expect($parcel->fresh()->status)->toBe('delivered');
Http::assertSent(fn (Request $request) =>
$request->url() === 'https://api.example.com/v1/parcels/123'
&& $request->hasHeader('Authorization')
);
});
Сценарії збоїв - так само просто:
Http::fake(['api.example.com/*' => Http::response(status: 503)]);
// послідовність: спершу помилка, потім успіх - перевірка повторів
Http::fake([
'api.example.com/*' => Http::sequence()
->pushStatus(503)
->push(['data' => ['status' => 'delivered']]),
]);
// мережевий збій (ConnectionException)
Http::fake(['api.example.com/*' => Http::failedConnection()]);
Http::preventStrayRequests() - будь-який запит без підготовленої фейкової відповіді кидає виняток. Найкраще ввімкнути глобально в базовому класі тестів чи Pest.php: тоді жоден тест випадково не піде в мережу, навіть якщо хтось забув Http::fake.
Корисні перевірки:
Http::assertSentCount(2)- скільки запитів зроблено;Http::assertNothingSent()- запитів не було (наприклад, через кеш);Http::assertSentInOrder([...])- порядок.
Реалістичні відповіді. Найкраще зберігати справжні (знеособлені) відповіді API у файлах-фікстурах і віддавати їх з Http::response(json_decode(file_get_contents(...))). Тести тоді перевіряють розбір саме того формату, що надсилає провайдер.
Пісочниці провайдерів (тестові ключі Stripe, LiqPay, Monobank) - для ручної перевірки й рідкісних інтеграційних тестів, які запускаються окремо від основного набору, а не для кожного прогону CI.
Обмеження фейків: вони перевіряють ваш код проти вашого уявлення про API. Якщо провайдер змінив формат, фейки цього не помітять - тут допомагають контрактні тести чи моніторинг помилок у продакшені.
Сторонній API - найменш контрольована частина системи: він буває повільним, недоступним, обмежує частоту запитів. Якщо викликати його прямо в обробнику запиту користувача, всі його проблеми стають вашими.
Що відбувається без черги:
public function store(Request $request)
{
$order = Order::create($request->validated());
Http::post('https://crm.example.com/api/deals', [...]); // 8 секунд...
Http::post('https://sms.example.com/send', [...]); // ...і впав з 503
return redirect()->route('orders.show', $order);
}
- користувач чекає, поки відповідять усі сторонні сервіси;
- падіння CRM ламає оформлення замовлення, хоча замовлення вже створене;
- процеси PHP-FPM зайняті очікуванням мережі - під навантаженням вони закінчуються, і сайт гальмує повністю;
- повторити невдалий виклик ніхто не спробує.
З чергою:
public function store(Request $request)
{
$order = Order::create($request->validated());
SyncOrderToCrm::dispatch($order)->afterCommit();
SendOrderSms::dispatch($order)->afterCommit();
return redirect()->route('orders.show', $order);
}
Що дає черга:
- швидка відповідь користувачу незалежно від сторонніх сервісів;
- повторні спроби з затримкою (
$tries,backoff()) - тимчасові збої вирішуються самі; - ізоляція: окрема черга для інтеграцій з власними воркерами - повільний провайдер не затримує листи чи генерацію звітів;
- обмеження частоти через middleware джоби (
RateLimited,ThrottlesExceptions) - під ліміти провайдера; - видимість: невдалі джоби в
failed_jobsчи Horizon, їх можна повторити вручну.
afterCommit() - джоба потрапить у чергу лише після коміту транзакції. Інакше воркер може взяти її раніше, ніж замовлення з'явиться в базі.
Коли синхронний виклик виправданий: результат потрібен одразу для відповіді (перевірка адреси доставки, розрахунок вартості, ініціалізація оплати з перенаправленням). Тоді - короткий тайм-аут і зрозуміле повідомлення користувачу при збої.
Джоба має бути ідемпотентною: повтор не повинен створити в CRM дві угоди. Зберігайте зовнішній ідентифікатор після першого успішного виклику і перевіряйте його перед створенням.
Базове правило: ключі не потрапляють у код і в Git. Вони живуть у змінних оточення, а код звертається до них через конфігурацію.
# .env - у .gitignore
STRIPE_SECRET=sk_live_...
NOVA_POSHTA_API_KEY=...
// config/services.php
'nova_poshta' => [
'key' => env('NOVA_POSHTA_API_KEY'),
'url' => env('NOVA_POSHTA_URL', 'https://api.novaposhta.ua/v2.0/json/'),
],
// у коді
Http::withToken(config('services.nova_poshta.key'));
Чому config(), а не env() у коді: після php artisan config:cache файл .env більше не читається, і env() поза конфігураційними файлами поверне null.
Що ще важливо:
.env.exampleмістить назви змінних без значень - новий розробник бачить, що потрібно налаштувати;- різні ключі для середовищ: тестові ключі пісочниці на локальній машині й staging, бойові - лише в продакшені;
- мінімальні права ключа: якщо провайдер дозволяє обмежити ключ (лише читання, певні IP, певні операції), - обмежувати;
- ротація: план заміни ключа без простою (новий ключ → деплой → відкликання старого) і обов'язкова заміна, якщо ключ міг витекти;
- не логувати секрети: заголовки
Authorization, тіла з ключами. У PHP 8.2+ атрибут#[\SensitiveParameter]приховує значення параметра в стеку помилки - Laravel використовує його, наприклад, уwithToken().
Зашифрований .env у репозиторії - вбудований механізм Laravel:
php artisan env:encrypt --env=production # створює .env.production.encrypted
php artisan env:decrypt --env=production --key=...
Зашифрований файл можна комітити, а ключ розшифрування передається лише в CI чи на сервер. Зручно для невеликих команд без окремого сховища секретів.
Для більших систем - менеджери секретів (Vault, AWS Secrets Manager, Doppler, секрети платформи деплою), де є аудит доступу й централізована ротація.
Токени користувачів до сторонніх сервісів (OAuth-доступ до їхніх Google чи GitHub) - це вже дані, а не конфігурація: зберігаються в базі зашифрованими (каст encrypted в Eloquent).
Ключ, що потрапив у Git, вважається скомпрометованим, навіть якщо коміт видалили: історія й форки лишаються. Тільки відкликання.
Задача: дізнатися, що в сторонньому сервісі щось змінилося - оплата пройшла, посилка доставлена, клієнт оновився в CRM.
Опитування (polling) - ваш застосунок періодично питає API:
Schedule::job(new RefreshParcelStatuses)->everyFifteenMinutes();
- плюси: просто, працює з будь-яким API, ви контролюєте частоту й момент; не потрібна публічна адреса;
- мінуси: затримка до інтервалу опитування; більшість запитів - марні («нічого не змінилося»); з'їдає ліміти частоти провайдера; погано масштабується на тисячі об'єктів.
Вебхуки - провайдер сам надсилає запит на ваш URL, коли подія сталася:
- плюси: майже миттєво; немає марних запитів; масштабується;
- мінуси: потрібна публічна HTTPS-адреса; треба перевіряти підпис; провайдер може не доставити (збій у нього, ваш простій) - подію буде втрачено або доставлено пізніше; дублікати й зміна порядку.
На практиці найнадійніше - поєднання:
- вебхук - як сигнал для швидкої реакції;
- періодичне опитування - як страховка (звірка): раз на годину чи добу перевірити об'єкти в «незавершених» станах (оплата «очікується» понад годину) і підтягнути їхній актуальний статус.
Так система не залежить від того, що кожен вебхук дійде.
Правила обробки вхідних вебхуків:
- перевірити підпис, відповісти
200швидко, а обробку - в чергу; - ідемпотентність за
idподії: дублікат не повинен обробитися двічі; - не покладатися на порядок подій - порівнювати з поточним станом чи запитувати свіжі дані з API;
- для локальної розробки - тунель (Expose, ngrok) чи CLI провайдера, що пересилає події на
localhost.
Коли лише опитування: провайдер не має вебхуків; дані змінюються рідко і затримка неважлива; застосунок не має публічної адреси (внутрішня мережа).
Коли лише вебхуки (без звірки): втрата події некритична, або провайдер гарантує повторну доставку довго й надійно, а у вас є журнал отриманих подій.
Метод retry повторює запит при помилці:
Http::retry(3, 200)->get($url); // 3 спроби, 200 мс між ними
Http::retry([100, 500, 2000])->get($url); // затримки по черзі
Http::retry(4, fn (int $attempt) => 2 ** $attempt * 100)->get($url); // експоненційно
Сигнатура: retry(array|int $times, Closure|int $sleepMilliseconds = 0, ?callable $when = null, bool $throw = true).
Третій аргумент - коли повторювати. За замовчуванням повтор іде при будь-якій помилці - і клієнтській (4xx), і серверній. Це рідко правильно:
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
Http::retry(3, 300, function (Throwable $exception, PendingRequest $request) {
if ($exception instanceof ConnectionException) {
return true; // мережа, тайм-аут
}
return $exception instanceof RequestException
&& in_array($exception->response->status(), [429, 500, 502, 503, 504], true);
})->get($url);
Що повторювати: мережеві збої, тайм-аути, 502/503/504, 429 (краще з урахуванням Retry-After).
Що НЕ повторювати:
- 400, 404, 422 - повтор дасть ту саму відповідь;
- 401/403 - хіба що після оновлення токена: колбек може змінити запит (
$request->withToken($newToken)) і повернутиtrue; - неідемпотентні операції (
POSTна створення платежу, відправка SMS) без ключа ідемпотентності: таймаут не означає, що запит не дійшов - повтор може списати гроші двічі.
throw: false - після всіх спроб повернути останню відповідь замість винятку. Але ConnectionException кидається все одно, якщо всі спроби впали через з'єднання.
Повтори в запиті користувача vs у черзі:
- синхронно - лише короткі повтори (1-2 спроби, сотні мілісекунд): користувач чекає, а процес PHP зайнятий;
- у джобі - довгі затримки краще віддати черзі (
$tries,backoff()): джоба звільняє воркер між спробами, а не спить уusleep.
Не множити повтори. Http::retry(3) всередині джоби з $tries = 5 - до 15 запитів на одну операцію, а з повторами на рівні проксі чи SDK - ще більше. Кожен рівень, що повторює, - вирішується свідомо.
Випадковий розкид затримки (jitter) для масових інтеграцій - щоб сотні джоб після збою провайдера не вдарили по ньому одночасно.
Послідовні запити складають свої затримки: три запити по 400 мс - 1,2 секунди. Якщо вони незалежні, їх можна виконати одночасно - і чекати лише найповільніший.
use Illuminate\Http\Client\Pool;
use Illuminate\Support\Facades\Http;
$responses = Http::pool(fn (Pool $pool) => [
$pool->as('rates')->get('https://api.example.com/rates'),
$pool->as('stock')->withToken($token)->get('https://warehouse.example.com/stock/42'),
$pool->as('delivery')->timeout(5)->get('https://delivery.example.com/estimate', ['city' => 'Київ']),
]);
$rates = $responses['rates']->json();
Особливості пулу:
- заголовки й налаштування - на кожному запиті в пулі:
Http::withHeaders(...)->pool(...)не застосується до запитів усередині; as()дає доступ до відповідей за назвою, без нього - за індексом у порядку додавання;- помилки не кидаються: невдалий запит повертає відповідь з кодом помилки, а збій з'єднання - об'єкт винятку на місці відповіді. Перевіряйте кожен результат:
$response instanceof Response && $response->successful().
Обмеження паралельності - якщо запитів багато:
$responses = Http::pool(fn (Pool $pool) => collect($skus)
->map(fn ($sku) => $pool->as($sku)->get("https://api.example.com/products/{$sku}"))
->all(), concurrency: 5);
Без обмеження сотня запитів піде одночасно - і провайдер відповість 429 або заблокує ключ.
Http::batch() - схожий механізм з колбеками before, progress, then, catch, finally - зручно для відстеження прогресу й обробки окремих збоїв.
Коли пул доречний:
- кілька незалежних джерел для однієї сторінки чи розрахунку (курси, залишки, доставка);
- масове оновлення даних у джобі - з
concurrencyпід ліміт провайдера.
Чого пул не вирішує:
- сумарне навантаження: паралельність прискорює ваш процес, але не зменшує кількість запитів до провайдера;
- ліміти частоти - для тисяч запитів потрібна черга з
RateLimited, а не один гігантський пул; - довгі операції в запиті користувача - навіть паралельно, три запити по 5 секунд - це 5 секунд очікування. Такі речі - у фоні.
Альтернатива поза HTTP-клієнтом - Concurrency::run() для паралельного виконання довільних замикань (окремі процеси), коли треба розпаралелити не лише HTTP.
Провайдери обмежують частоту запитів: «60 на хвилину», «10 на секунду на ключ». Десять воркерів, що паралельно синхронізують тисячі записів, легко перевищать ліміт - і отримають 429 або тимчасове блокування ключа.
1. Обмежувач частоти для джоб - RateLimiter::for() + middleware RateLimited:
// AppServiceProvider::boot()
RateLimiter::for('crm', fn (object $job) => Limit::perMinute(50));
use Illuminate\Queue\Middleware\RateLimited;
final class SyncContactToCrm implements ShouldQueue
{
public function middleware(): array
{
return [new RateLimited('crm')];
}
public function retryUntil(): DateTime
{
return now()->plus(hours: 6);
}
}
Джоба, що перевищила ліміт, повертається в чергу з затримкою. Важливо: повернення збільшує лічильник спроб - тому замість малого $tries використовують retryUntil() (обмеження за часом).
Ліміт спільний для всіх воркерів і серверів, бо зберігається в кеші (для Redis є RateLimitedWithRedis).
2. Реакція на 429 від провайдера. Навіть з власним обмежувачем провайдер може відповісти 429 (інші клієнти того самого ключа, інша методика підрахунку). Поважайте Retry-After:
$response = Http::crm()->post('/contacts', $payload);
if ($response->status() === 429) {
$this->release((int) $response->header('Retry-After') ?: 60);
return;
}
3. ThrottlesExceptions - якщо провайдер почав масово падати, перестати його смикати:
return [(new ThrottlesExceptions(10, 5 * 60))->by('crm-api')->backoff(1)];
Після 10 винятків поспіль джоби з тим самим ключем чекають 5 хвилин. by() об'єднує різні джоби, що ходять до одного провайдера, у спільний «кошик».
4. Окрема черга з обмеженою кількістю воркерів - найпростіший грубий обмежувач: черга crm з двома процесами у Horizon фізично не зробить більше двох запитів одночасно.
5. Зменшити кількість запитів: пакетні ендпойнти провайдера (оновити 100 записів одним запитом), кешування довідників, ShouldBeUnique - щоб не ставити в чергу синхронізацію того самого запису десять разів поспіль.
Моніторинг: кількість 429 і довжина черги інтеграцій - перші сигнали, що ліміт треба переглянути (чи купувати вищий тариф API).
Коли виклики до одного API розкидані по контролерах і джобах (Http::withToken(...)->get('https://...') у двадцяти місцях), будь-яка зміна - нова версія API, інший заголовок, логування - перетворюється на пошук по всьому проєкту. Налаштування треба зібрати в одному місці.
Рівень 1 - макрос з базовими налаштуваннями:
// AppServiceProvider::boot()
Http::macro('novaPoshta', fn () => Http::baseUrl(config('services.nova_poshta.url'))
->acceptJson()
->connectTimeout(3)
->timeout(10)
->retry(2, 300, fn ($e) => $e instanceof ConnectionException));
// використання
$response = Http::novaPoshta()->post('/', $payload);
Добре для невеликої кількості викликів: спільні адреса, тайм-аути, автентифікація.
Рівень 2 - клас-клієнт з методами предметної області:
final class NovaPoshtaClient
{
public function __construct(private readonly string $apiKey) {}
public function trackParcel(string $number): ParcelStatus
{
$response = Http::novaPoshta()
->post('/', [
'apiKey' => $this->apiKey,
'modelName' => 'TrackingDocument',
'calledMethod' => 'getStatusDocuments',
'methodProperties' => ['Documents' => [['DocumentNumber' => $number]]],
])
->throw();
return ParcelStatus::fromApi($response->json('data.0'));
}
}
Реєстрація в контейнері ($this->app->singleton(...) з ключем з конфігурації) і впровадження в конструктори.
Що дає клас:
- решта коду не знає про HTTP: він викликає
trackParcel()і отримує DTO, а не масив з дивними ключами провайдера; - один місце для перетворення зовнішнього формату у ваші типи - зміни провайдера не розходяться по проєкту;
- легко підмінити в тестах - фейковою реалізацією інтерфейсу або
Http::fake(); - місце для кешування, логування, обробки специфічних помилок провайдера.
Рівень 3 - SDK. Якщо провайдер має офіційний PHP SDK, часто розумно ним скористатися - але через власний клас-обгортку, щоб SDK не проник у весь код. Для власних SDK до великих API - бібліотека Saloon з конекторами, запитами, DTO й тестовими моками.
Правило: кількість шарів - за складністю інтеграції. Один виклик - макросу досить; ключова інтеграція з десятками методів (платежі, CRM, склад) - клас-клієнт або SDK з чіткими типами на вході й виході.
Багато API вимагають не статичний ключ, а короткоживучий токен доступу, який треба періодично отримувати й оновлювати.
Client Credentials - сервер звертається до API від імені свого застосунку, без участі користувача (інтеграції між системами):
final class WarehouseToken
{
public function get(): string
{
return Cache::remember('warehouse:access_token', now()->plus(minutes: 50), function () {
$response = Http::asForm()
->post(config('services.warehouse.token_url'), [
'grant_type' => 'client_credentials',
'client_id' => config('services.warehouse.client_id'),
'client_secret' => config('services.warehouse.client_secret'),
'scope' => 'stock:read',
])
->throw();
return $response->json('access_token');
});
}
}
Ключові моменти:
- кешувати токен на термін трохи менший за
expires_in(запас на годинники й тривалі запити). Запит нового токена на кожен виклик API - зайві затримки і можливе блокування за частотою; - конкурентне оновлення: коли токен закінчився, десяток воркерів одночасно підуть за новим. Захист - блокування (
Cache::lock('warehouse:token')) чиCache::flexibleдля фонового оновлення; - повтор при 401: токен могли відкликати раніше терміну - скинути кеш, отримати новий і повторити запит один раз:
Http::withToken($token->get())
->retry(2, 0, function ($exception, PendingRequest $request) use ($token) {
if (! $exception instanceof RequestException || $exception->response->status() !== 401) {
return false;
}
$request->withToken($token->refresh());
return true;
})
->get($url);
Токени від імені користувача (Authorization Code: доступ до Google Calendar користувача, його GitHub):
- зберігати access token і refresh token у базі, зашифрованими (каст
encrypted); - оновлювати access token через refresh token, коли закінчився термін, і зберігати новий refresh token, якщо провайдер його видав (багато провайдерів ротують refresh token при кожному використанні - старий перестає працювати);
- обробляти відкликання доступу (користувач відключив застосунок):
invalid_grant- позначити інтеграцію неактивною й попросити підключити знову; - у Laravel вхід через провайдерів і отримання токенів - Socialite.
Секрети клієнта (client_secret) - у .env/менеджері секретів, ніколи у фронтенді. Мінімальні scope - лише потрібні права.
Коли сторонній сервіс лежить, повтори й тайм-аути не допомагають, а шкодять: кожен запит чекає повний тайм-аут, воркери й процеси PHP зайняті очікуванням, черга росте, а сервіс, що намагається піднятися, отримує лавину запитів.
Circuit Breaker («запобіжник») відстежує помилки й після порогу перестає викликати сервіс на певний час:
Closed (норма) -- N помилок поспіль --> Open (виклики не йдуть, одразу помилка)
^ |
| минув час
| v
+----- успіх ---- Half-open (пропустити пробний запит) ---- помилка ---> Open
Що це дає:
- швидка відмова замість 30-секундного очікування: користувач одразу бачить запасний варіант, воркери вільні;
- сервісу дають відновитися без лавини повторів;
- запасна поведінка: закешовані дані, відкладена обробка, повідомлення «сервіс тимчасово недоступний».
У Laravel для фонових задач роль запобіжника виконує middleware ThrottlesExceptions:
public function middleware(): array
{
return [
(new ThrottlesExceptions(maxAttempts: 10, decaySeconds: 5 * 60))
->by('payment-provider') // спільно для всіх джоб провайдера
->when(fn (Throwable $e) => $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError())),
];
}
Після 10 помилок джоби з цим ключем не виконуються 5 хвилин, а повертаються в чергу. З retryUntil() вони не губляться.
Для синхронних викликів простий запобіжник на кеші:
final class CircuitBreaker
{
public function __construct(private string $service, private int $threshold = 5, private int $cooldown = 60) {}
public function call(callable $callback, callable $fallback): mixed
{
if (Cache::has("circuit:{$this->service}:open")) {
return $fallback();
}
try {
$result = $callback();
Cache::forget("circuit:{$this->service}:failures");
return $result;
} catch (ConnectionException|RequestException $e) {
if (Cache::increment("circuit:{$this->service}:failures") >= $this->threshold) {
Cache::put("circuit:{$this->service}:open", true, $this->cooldown);
}
return $fallback();
}
}
}
Що важливо:
- рахувати лише «системні» помилки (мережа, 5xx, тайм-аути), а не 4xx від ваших же некоректних запитів;
- стан спільний для всіх процесів - у Redis, а не в пам'яті одного воркера;
- сповіщення: перехід у стан Open - подія для моніторингу й чергового;
- короткі тайм-аути - запобіжник не замінює їх, а доповнює.
Готові рішення є й у вигляді пакетів, але для більшості Laravel-застосунків ThrottlesExceptions у черзі + короткі тайм-аути + кеш як запасний варіант покривають потреби.
У інтеграціях дублікати неминучі: провайдер повторює вебхук, бо не дочекався відповіді; джоба повторюється після тайм-ауту, хоча перша спроба насправді завершилася; користувач двічі натискає «Синхронізувати». Обробка має давати той самий результат незалежно від кількості повторів.
1. Унікальність на вході - журнал оброблених подій:
Schema::create('processed_events', function (Blueprint $table) {
$table->string('provider');
$table->string('event_id');
$table->timestamp('processed_at');
$table->primary(['provider', 'event_id']);
});
public function handle(): void
{
DB::transaction(function () {
$inserted = DB::table('processed_events')->insertOrIgnore([
'provider' => 'stripe',
'event_id' => $this->event['id'],
'processed_at' => now(),
]);
if ($inserted === 0) {
return; // уже оброблено
}
$this->applyPayment($this->event);
});
}
Унікальний ключ у базі - єдиний надійний захист від гонитви: перевірка «чи є запис» і вставка окремими запитами пропускають паралельні дублікати.
2. Ідемпотентні операції за природою:
updateOrCreate/upsertза зовнішнім ідентифікатором (external_id) замістьcreate;- «встановити статус
paid» замість «збільшити суму на X»; - переходи станів з перевіркою: «оплачене» не можна оплатити вдруге.
3. Унікальні джоби - щоб не ставити в чергу однакову роботу:
#[UniqueFor(600)]
final class SyncCustomer implements ShouldQueue, ShouldBeUnique
{
public function uniqueId(): string
{
return (string) $this->customerId;
}
}
Поки джоба для цього клієнта в черзі, нові такі ж відкидаються. Потребує кешу з атомарними блокуваннями і спільного кешу для всіх серверів.
4. Вихідні операції з побічними ефектами (створення платежу, відправка SMS) - ключ ідемпотентності в запиті до провайдера, стабільний для повторів тієї самої операції (Idempotency-Key: order-42-capture). Тоді повтор джоби після тайм-ауту не створить другий платіж.
5. Звірка (reconciliation) - останній рубіж: періодичне порівняння стану у вас і в провайдера (оплати за добу, залишки на складі), автоматичне виправлення розбіжностей і звіт про них. Ловить те, що загубилося попри всі механізми.
Типова помилка: покладатися на ShouldBeUnique як на захист від повторної обробки. Він не дає поставити дублікат у чергу, але не захищає від повторного виконання вже взятої джоби після збою воркера - для цього потрібні ідемпотентні операції й унікальні ключі в базі.
Saloon - бібліотека для PHP (з інтеграцією в Laravel), що структурує роботу з API у класи: конектор описує API загалом, запит - один ендпойнт.
use Saloon\Http\Connector;
use Saloon\Traits\Plugins\AcceptsJson;
final class CrmConnector extends Connector
{
use AcceptsJson;
public ?int $tries = 3;
public ?int $retryInterval = 500;
public ?bool $useExponentialBackoff = true;
public function __construct(private readonly string $token) {}
public function resolveBaseUrl(): string
{
return 'https://crm.example.com/api/v2';
}
protected function defaultAuth(): TokenAuthenticator
{
return new TokenAuthenticator($this->token);
}
}
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Http\Response;
final class GetDeal extends Request
{
protected Method $method = Method::GET;
public function __construct(private readonly int $id) {}
public function resolveEndpoint(): string
{
return "/deals/{$this->id}";
}
public function createDtoFromResponse(Response $response): Deal
{
return Deal::fromArray($response->json('data'));
}
}
$deal = $connector->send(new GetDeal(42))->dto(); // Deal, а не масив
Що дає порівняно з Http:: у класі-клієнті:
- один запит - один клас: ендпойнт, метод, тіло, заголовки, перетворення відповіді в DTO живуть разом. Великий API (десятки ендпойнтів) не перетворюється на клас-клієнт на тисячу рядків;
- DTO на виході (
createDtoFromResponse+dto()) - решта застосунку не бачить формату провайдера; - повтори, автентифікація, пагінація, OAuth2 - готові механізми на рівні конектора;
- тести:
MockClientз відповідями для конкретних класів запитів і фікстури - записані справжні відповіді, що відтворюються в тестах; - middleware на рівні конектора - логування, метрики, ідентифікатори кореляції в одному місці.
Як не перестаратися:
- для двох-трьох викликів Saloon - зайва церемонія; макрос чи невеликий клас-клієнт простіші;
- DTO мають відображати потреби вашого коду, а не повністю копіювати відповідь провайдера: мапінг лише потрібних полів, перетворення типів (дати, гроші в мінімальних одиницях, енуми статусів) і явна обробка відсутніх полів;
- не тягнути SDK у предметну область: сервіси застосунку залежать від вашого інтерфейсу (
CrmGateway), а Saloon - деталь його реалізації. Тоді зміна провайдера чи бібліотеки не зачіпає бізнес-логіку.
Коли Saloon особливо доречний: ключова інтеграція з великою кількістю ендпойнтів, кілька інтеграцій з однаковими підходами в команді, публікація власного SDK для свого API.
Коли «не приходять SMS» чи «замовлення не потрапили в CRM», перше питання - що саме відбувалося між вашим застосунком і провайдером. Без записів про вихідні запити відповісти неможливо.
Події HTTP-клієнта - точка для централізованого логування без зміни коду інтеграцій:
use Illuminate\Http\Client\Events\ConnectionFailed;
use Illuminate\Http\Client\Events\ResponseReceived;
Event::listen(function (ResponseReceived $event) {
Log::channel('integrations')->info('http.out', [
'host' => parse_url($event->request->url(), PHP_URL_HOST),
'method' => $event->request->method(),
'path' => parse_url($event->request->url(), PHP_URL_PATH),
'status' => $event->response->status(),
'duration_ms' => round(($event->response->transferStats?->getTransferTime() ?? 0) * 1000),
]);
});
Event::listen(fn (ConnectionFailed $event) => Log::channel('integrations')->warning('http.out.failed', [
'url' => $event->request->url(),
]));
Також є RequestSending перед відправкою і глобальні middleware клієнта (Http::globalRequestMiddleware, globalResponseMiddleware) - наприклад, щоб додати заголовок кореляції до всіх вихідних запитів.
Що варто записувати:
- провайдер, ендпойнт (без параметрів з персональними даними), метод, статус, тривалість, номер спроби;
- ідентифікатор запиту провайдера з заголовка відповіді (
X-Request-Id,CF-Ray) - саме його попросить підтримка провайдера; - ваш ідентифікатор кореляції - щоб зв'язати вихідний запит з HTTP-запитом користувача чи джобою, що його породили (
Context::add('trace_id', ...)в Laravel додає його в усі журнали); - для збоїв - фрагмент тіла відповіді з помилкою.
Чого НЕ записувати: токени й ключі (Authorization), паролі, номери карток, повні тіла з персональними даними. Маскування - в одному місці, у слухачі подій.
Метрики й сповіщення важливіші за журнали:
- частка помилок і латентність за провайдером (p95);
- кількість 429 - наближення до лімітів;
- довжина черги інтеграцій і кількість невдалих джоб;
- сповіщення, коли частка помилок провайдера перевищує поріг.
Інструменти в Laravel-екосистемі: Pulse (картки повільних вихідних запитів), Telescope (на staging - детально кожен запит), Nightwatch, OpenTelemetry для розподіленого трасування.
Журнал бізнес-операцій інтеграції (таблиця «синхронізації»: що відправлено, коли, результат, зовнішній ID) - окремо від технічних журналів. Його показують підтримці й адміністраторам і використовують для звірки.
Пісочниці провайдерів у staging - щоб збої інтеграцій ловилися до продакшену, а моніторинг працював і там.