Построение надёжного REST API на Laravel с автоматическим тестированием и документацией в CI/CD пайплайне
Введение: почему тестирование и документация API критичны в 2026 году
В 2026 году REST API — это не просто набор эндпоинтов, это контракт между командами, системами и бизнесом. Сломанный эндпоинт в продакшене стоит денег и репутации. Устаревшая документация заставляет фронтенд-команды тратить часы на отладку вместо разработки. CI/CD закрывает оба вопроса: каждый коммит автоматически проходит через тесты, генерирует актуальную документацию и только после этого попадает в продакшен.
Laravel остаётся одним из самых популярных PHP-фреймворков для построения API благодаря встроенной поддержке тестирования, богатой экосистеме и выразительному синтаксису. В этой статье мы пройдём весь путь: от архитектуры проекта до деплоя документации через GitHub Actions.
Архитектура проекта: слои, паттерны и структура
Хорошо структурированный Laravel-проект — основа тестируемого кода. Используем Repository и Service паттерны для разделения ответственности.
Типичная структура директорий для API-проекта:
app/
├── Http/
│ ├── Controllers/Api/V1/
│ │ ├── AuthController.php
│ │ └── ProductController.php
│ ├── Requests/
│ │ └── StoreProductRequest.php
│ └── Resources/
│ └── ProductResource.php
├── Services/
│ └── ProductService.php
├── Repositories/
│ ├── Contracts/
│ │ └── ProductRepositoryInterface.php
│ └── ProductRepository.php
└── Models/
└── Product.php
Сервисный слой содержит бизнес-логику, репозиторий отвечает за взаимодействие с базой данных. Контроллер остаётся тонким — только принять запрос, делегировать и вернуть ответ.
<?php
namespace App\Services;
use App\Models\Product;
use App\Repositories\Contracts\ProductRepositoryInterface;
use Illuminate\Pagination\LengthAwarePaginator;
class ProductService
{
public function __construct(
private readonly ProductRepositoryInterface $repository
) {}
public function getPaginated(int $perPage = 15): LengthAwarePaginator
{
return $this->repository->paginate($perPage);
}
public function create(array $data): Product
{
return $this->repository->create($data);
}
}
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Requests\StoreProductRequest;
use App\Http\Resources\ProductResource;
use App\Services\ProductService;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ProductController extends ApiController
{
public function __construct(
private readonly ProductService $service
) {}
public function index(): AnonymousResourceCollection
{
return ProductResource::collection(
$this->service->getPaginated()
);
}
public function store(StoreProductRequest $request): ProductResource
{
$product = $this->service->create($request->validated());
return new ProductResource($product);
}
}
Такая архитектура позволяет подменять реализации в тестах через DI-контейнер Laravel, не трогая бизнес-логику.
Написание Feature-тестов для REST API на Laravel
Laravel поставляется с PHPUnit из коробки. Дополнительно рекомендуем установить Pest — более выразительный синтаксис без бойлерплейта.
composer require pestphp/pest pestphp/pest-plugin-laravel --dev
./vendor/bin/pest --init
Фабрики и фикстуры
Model Factories — основа изолированных тестов. Не используйте общую базу данных для тестов: применяйте трейт RefreshDatabase или DatabaseTransactions.
<?php
namespace Database\Factories;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;
class ProductFactory extends Factory
{
protected $model = Product::class;
public function definition(): array
{
return [
'name' => $this->faker->words(3, true),
'price' => $this->faker->randomFloat(2, 10, 1000),
'description' => $this->faker->paragraph(),
'sku' => strtoupper($this->faker->bothify('??-####')),
'in_stock' => true,
];
}
public function outOfStock(): static
{
return $this->state(['in_stock' => false]);
}
}
Feature-тест с авторизацией (Pest)
<?php
use App\Models\Product;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class);
describe('Products API', function () {
beforeEach(function () {
$this->user = User::factory()->create();
Sanctum::actingAs($this->user);
});
it('returns paginated list of products', function () {
Product::factory()->count(20)->create();
$this->getJson('/api/v1/products')
->assertOk()
->assertJsonStructure([
'data' => [['id', 'name', 'price', 'sku']],
'meta' => ['current_page', 'total', 'per_page'],
])
->assertJsonCount(15, 'data');
});
it('creates a product with valid data', function () {
$payload = Product::factory()->make()->toArray();
$this->postJson('/api/v1/products', $payload)
->assertCreated()
->assertJsonPath('data.name', $payload['name']);
$this->assertDatabaseHas('products', ['sku' => $payload['sku']]);
});
it('returns 422 when price is missing', function () {
$this->postJson('/api/v1/products', ['name' => 'Test'])
->assertUnprocessable()
->assertJsonValidationErrors(['price']);
});
it('returns 401 for unauthenticated request', function () {
// Сброс аутентификации
$this->withoutMiddleware(\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class);
$this->getJson('/api/v1/products', ['Authorization' => ''])
->assertUnauthorized();
});
});
Измерение покрытия кода
Добавьте в phpunit.xml настройки покрытия:
<coverage>
<include>
<directory suffix=".php">./app</directory>
</include>
<report>
<html outputDirectory="coverage-report"/>
<clover outputFile="coverage.xml"/>
</report>
</coverage>
Запуск с покрытием: ./vendor/bin/pest --coverage --min=80 — флаг --min завалит сборку, если покрытие ниже 80%.
Контрактное тестирование REST API
Контрактное тестирование гарантирует, что API соответствует согласованному контракту — структуре запросов и ответов, которую ожидают потребители. Это критично при микросервисной архитектуре.
Для PHP/Laravel подходят два инструмента:
- Pact PHP (
pact-foundation/pact-php) — полноценный Pact-совместимый фреймворк, поддерживает Pact Broker. - Spectator (
hotmeteor/spectator) — более простой вариант: валидирует запросы и ответы против вашего OpenAPI-файла прямо в PHPUnit/Pest тестах.
Пример с Spectator:
composer require hotmeteor/spectator --dev
<?php
use Spectator\Spectator;
uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(fn() => Spectator::using('api-v1.yaml'));
it('GET /products matches OpenAPI spec', function () {
Product::factory()->count(5)->create();
$this->getJson('/api/v1/products')
->assertValidRequest()
->assertValidResponse(200);
});
Если структура ответа расходится со схемой api-v1.yaml, тест завалится. Это исключает ситуацию «документация говорит одно, API отвечает другое».
Автогенерация OpenAPI/Swagger документации из кода
Ручное написание Swagger-документации устаревает быстрее кода. Решение — генерировать документацию автоматически из аннотаций или атрибутов PHP.
Пакет darkaonline/l5-swagger
composer require darkaonline/l5-swagger
php artisan vendor:publish --provider="L5Swagger\L5SwaggerServiceProvider"
Аннотируйте контроллеры с помощью атрибутов OpenApi:
<?php
use OpenApi\Attributes as OA;
#[OA\Get(
path: '/api/v1/products',
summary: 'Список продуктов',
tags: ['Products'],
parameters: [
new OA\Parameter(
name: 'page',
in: 'query',
required: false,
schema: new OA\Schema(type: 'integer', default: 1)
)
],
responses: [
new OA\Response(
response: 200,
description: 'Успешный ответ',
content: new OA\JsonContent(
properties: [
new OA\Property(
property: 'data',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Product')
)
]
)
),
new OA\Response(response: 401, description: 'Не авторизован')
]
)]
public function index(): AnonymousResourceCollection
{
return ProductResource::collection($this->service->getPaginated());
}
Генерация документа: php artisan l5-swagger:generate. Результат — файл storage/api-docs/api-docs.json, который можно подключить к Swagger UI или ReDoc.
Альтернатива: knuckleswtf/scribe
Scribe анализирует FormRequest-классы, Route-аннотации и тест-трассы, генерируя документацию с минимальными аннотациями. Подходит, если вы хотите получить документацию быстро без детальной разметки:
composer require knuckleswtf/scribe --dev
php artisan scribe:generate
Интеграция в CI/CD: GitHub Actions
Весь цикл — тесты, покрытие, генерация документации, деплой — должен запускаться автоматически при каждом пуше в основные ветки.
name: Laravel API CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: api_test
POSTGRES_USER: api_user
POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: --health-cmd "redis-cli ping" --health-interval 10s
steps:
- uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_pgsql, redis, pcov
coverage: pcov
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Copy .env
run: cp .env.testing.example .env.testing
- name: Generate app key
run: php artisan key:generate --env=testing
- name: Run migrations
env:
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_DATABASE: api_test
DB_USERNAME: api_user
DB_PASSWORD: secret
run: php artisan migrate --env=testing --force
- name: Run Pest tests with coverage
env:
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_DATABASE: api_test
DB_USERNAME: api_user
DB_PASSWORD: secret
REDIS_HOST: 127.0.0.1
run: ./vendor/bin/pest --coverage --min=80 --coverage-clover=coverage.xml
- name: Upload coverage report
uses: codecov/codecov-action@v4
with:
file: coverage.xml
generate-docs:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Generate Swagger docs
run: php artisan l5-swagger:generate
- name: Deploy docs to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./storage/api-docs
destination_dir: api-docs
Ключевые моменты этого пайплайна:
- Job
testподнимает PostgreSQL 16 и Redis 7 как сервисы GitHub Actions — это изолированное окружение, идентичное продакшену. - Флаг
--min=80в Pest останавливает пайплайн, если покрытие ниже порога. - Job
generate-docsзапускается только после успешного прохождения тестов (needs: test) и только в веткеmain. - Документация автоматически публикуется на GitHub Pages.
Docker для тестовой среды
Для локальной разработки и воспроизводимости среды используем Docker. Файл docker-compose.testing.yml:
version: '3.9'
services:
app:
build:
context: .
dockerfile: Dockerfile.testing
volumes:
- .:/var/www/html
environment:
APP_ENV: testing
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_DATABASE: api_test
DB_USERNAME: api_user
DB_PASSWORD: secret
REDIS_HOST: redis
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
command: ./vendor/bin/pest --coverage
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: api_test
POSTGRES_USER: api_user
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U api_user -d api_test"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
Dockerfile.testing — минималистичный образ для тестов:
FROM php:8.3-cli-alpine
RUN apk add --no-cache postgresql-dev \
&& docker-php-ext-install pdo_pgsql pcntl \
&& pecl install redis pcov \
&& docker-php-ext-enable redis pcov
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
Запуск тестов локально: docker compose -f docker-compose.testing.yml up --abort-on-container-exit. Это полностью воспроизводит CI-окружение на машине разработчика.
Практические советы: что тестировать обязательно
Обязательно тестируйте
- Happy path каждого эндпоинта — корректные данные, ожидаемый статус и структура ответа.
- Авторизацию и права доступа — неавторизованный запрос должен возвращать 401, запрос без нужной роли — 403.
- Валидацию входных данных — отсутствующие обязательные поля, неверные типы, граничные значения.
- Пагинацию — корректность метаданных
meta.total,meta.per_page, поведение на последней странице. - Конкурентные запросы на критичные операции — например, двойное списание баланса.
- Соответствие ответа OpenAPI-схеме через Spectator.
Можно пропустить или отложить
- Тестирование сторонних SDK и библиотек — они уже протестированы их авторами.
- Тривиальные геттеры/сеттеры моделей без логики.
- Сложные UI-сценарии, не связанные с API-контрактом.
Практические правила
- Один тест — один сценарий. Не проверяйте в одном тесте и создание, и удаление.
- Используйте
assertJsonPath()вместоassertJson()для точечных проверок без жёсткой привязки к полной структуре. - Мокайте внешние HTTP-запросы через
Http::fake()— тесты не должны зависеть от сети. - Добавляйте тест на каждый найденный баг перед его исправлением — это предотвращает регрессии.
Заключение и чеклист для production-ready API
Production-ready Laravel REST API в 2026 году — это не просто рабочий код. Это предсказуемый контракт, автоматически верифицируемый при каждом изменении. CI/CD объединяет тестирование, документацию и деплой в единый автоматизированный процесс, который устраняет человеческий фактор из критических операций.
Код без тестов — это код, который вы боитесь трогать. Документация без автогенерации — это документация, которой никто не доверяет.
Чеклист production-ready Laravel API
- Архитектура разделена на слои: Controller → Service → Repository → Model.
- Все публичные эндпоинты покрыты Feature-тестами (happy path + edge cases).
- Авторизация протестирована: 401 для неавторизованных, 403 для запрещённых действий.
- Валидация проверена на невалидные данные с проверкой кодов ошибок.
- Контрактные тесты через Spectator валидируют ответы против OpenAPI-схемы.
- Swagger/OpenAPI документация генерируется автоматически из аннотаций.
- GitHub Actions запускает тесты на каждый PR и пуш в
main. - Покрытие кода не ниже 80%, минимальный порог встроен в CI.
- Docker изолирует тестовую среду с реальными PostgreSQL и Redis.
- Документация автоматически деплоится при мерже в
main. - Внешние HTTP-запросы мокируются через
Http::fake(). - Каждый найденный баг сопровождается регрессионным тестом.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →