До 2022 года я писал API без Swagger. Были контроллеры, JSON, коллекции Postman и сообщения коллегам в духе «сюда передаём вот это, получаем примерно такое». Для двух разработчиков и десятка методов этого хватало — до первого расхождения между кодом и объяснением.
На одном из проектов API одновременно понадобился нескольким командам. Вопросов стало больше, чем методов: какие поля обязательны, как выглядит ошибка, какой код вернётся без авторизации, можно ли повторить запрос. Фраза «посмотри Swagger» впервые заменила мне длинный разговор с бэкенд-разработчиком.
Тогда документация перестала быть текстом, который пишут после реализации. Я начал относиться к ней как к публичной части самого API.
Swagger или OpenAPI
Точный термин сегодня — OpenAPI Specification. Swagger дал имя ранним версиям формата, а теперь так называют инструменты вокруг спецификации: Swagger UI, Swagger Editor и другие.
В рабочих разговорах всё равно спрашивают: «Swagger на API есть?» Я тоже использую привычное слово, когда речь не о версии стандарта, а о документации в целом.
API
+ машиночитаемое описание контракта
= понятный интерфейс между системамиEndpoint — это контракт
До знакомства с OpenAPI метод мог выглядеть так:
public function create(Request $request): JsonResponse
{
$data = $request->all();
$order = $this->service->create($data);
return response()->json($order);
}Код выполняется, но для клиента почти ничего не объясняет. Что лежит в $data? Какие поля обязательны? Какой формат даты? Что произойдёт при ошибке валидации или без токена? Вернётся 200 или 201?
URL и контроллер — только две детали. Мысленно я теперь собираю метод из девяти частей:
HTTP method + URL
authentication and permissions
input and validation
business operation
success response
error responses
HTTP status codesOpenAPI заставляет принять решения по каждой строке. Это полезнее самой страницы Swagger UI.
Контракт создания заказа
Возьмём POST /api/orders. Помимо пути я хочу видеть operationId, тег и короткое назначение метода. На API из пятидесяти операций группировка уже экономит время; на двухстах без неё поиск превращается в прокрутку.
Авторизация тоже входит в контракт:
Authorization: Bearer <token>401 Unauthorized — такой же ожидаемый результат вызова, как 201 Created. Если в документации указан только счастливый путь, она описывает демонстрацию, а не рабочий API.
Тело запроса может выглядеть так:
{
"customer_id": 124,
"delivery_date": "2026-09-04",
"comment": "Позвонить перед доставкой",
"items": [
{
"product_id": 15,
"quantity": 2
}
]
}Одного примера мало. У customer_id должен быть тип и обязательность, у delivery_date — формат date, у items — minItems: 1, у quantity — нижняя граница. Как только это записано, расхождения с кодом становятся видны.
Laravel: Form Request, DTO и Resource
В Laravel я не держу контракт входа внутри контроллера. Отдельный FormRequest делает правила видимыми и пригодными для сопоставления с OpenAPI:
final class StoreOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'customer_id' => ['required', 'integer', 'exists:customers,id'],
'delivery_date' => ['required', 'date_format:Y-m-d'],
'comment' => ['nullable', 'string', 'max:1000'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
];
}
}Контроллер после этого остаётся транспортным слоем:
public function store(
StoreOrderRequest $request,
CreateOrderAction $action,
): JsonResponse {
$order = $action->execute(
CreateOrderData::fromArray($request->validated()),
);
return response()->json(
OrderResource::make($order),
201,
);
}HTTP Request
↓
Validation
↓
Request DTO
↓
Application Action
↓
Resource
↓
HTTP ResponseДля Laravel я использовал L5-Swagger: пакет связывает swagger-php и Swagger UI с приложением. В новых проектах разумно сразу писать PHP Attributes; актуальная ветка пакета ушла от Doctrine annotations.
Не превращать контроллер в спецификацию
Первая попытка обычно заканчивается атрибутом над методом, внутри которого двадцать строк request body и ещё тридцать строк responses. Технически верно, читать контроллер невозможно.
Повторяемые структуры лучше вынести в компоненты:
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'CreateOrderRequest',
required: ['customer_id', 'delivery_date', 'items'],
properties: [
new OA\Property(property: 'customer_id', type: 'integer'),
new OA\Property(property: 'delivery_date', type: 'string', format: 'date'),
new OA\Property(
property: 'items',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/CreateOrderItem'),
),
],
)]
final class CreateOrderRequestSchema
{
}На endpoint остаются ссылки на CreateOrderRequest, OrderResponse, ValidationErrorResponse и UnauthorizedResponse. Контроллер снова показывает действие, а не весь словарь API.
Сам swagger-php не требует Laravel. Он сканирует PHP-код и собирает OpenAPI из Attributes, поэтому тот же подход работает в любом приложении на PHP.
Symfony: та же идея
В Symfony меняются инструменты, но не границы. Вместо ручного json_decode() я предпочитаю входящий DTO с Validator:
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreateOrderRequest
{
/** @param list<CreateOrderItemRequest> $items */
public function __construct(
#[Assert\Positive]
public int $customerId,
#[Assert\NotBlank]
#[Assert\Date]
public string $deliveryDate,
#[Assert\Length(max: 1000)]
public ?string $comment,
#[Assert\Count(min: 1)]
#[Assert\Valid]
public array $items,
) {
}
}В контроллере привязка payload должна быть явной:
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
public function __invoke(
#[MapRequestPayload] CreateOrderRequest $request,
CreateOrderHandler $handler,
): JsonResponse {
$order = $handler($request);
return $this->json(
OrderResponse::fromOrder($order),
Response::HTTP_CREATED,
);
}NelmioApiDocBundle умеет использовать маршруты, PHP Attributes, сериализаторы и MapRequestPayload. Часть схемы получается из кода, а вручную остаётся описать то, чего типы не выражают: права, бизнес-ошибки и смысл операции.
Request и response — разные контракты
Отдавать наружу Doctrine Entity или Eloquent Model почти без преобразования удобно только в начале. Структура базы незаметно становится публичным API, а затем любое переименование поля превращается в несовместимое изменение.
Я разделяю вход, внутреннюю модель и ответ:
Request DTO
↓
Application
↓
Domain / Model
↓
Response DTOfinal readonly class OrderResponse
{
public function __construct(
public int $id,
public string $number,
public string $status,
public string $createdAt,
) {
}
}OpenAPI описывает OrderResponse, а не таблицу orders. Это оставляет внутренней модели право меняться.
Успешный ответ и ошибки
Для создания заказа я ожидаю 201 Created и один выбранный формат ответа:
{
"data": {
"id": 1532,
"number": "ORD-1532",
"status": "new",
"created_at": "2026-09-01T09:30:00+03:00"
}
}Envelope не обязателен. Обязательна последовательность: если один endpoint возвращает data, второй result, а третий объект напрямую, клиенту приходится помнить историю каждой команды.
Ошибки ещё важнее счастливого пути. Я предпочитаю единый конверт и стабильный прикладной код:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"delivery_date": [
"This value is not a valid date."
]
}
}
}HTTP 422 объясняет класс результата, VALIDATION_ERROR — ситуацию в приложении. Клиенту нужны оба уровня. Те же схемы переиспользуются для 400, 401, 403, 404, 409, 422 и 500, но набор допустимых ответов задаётся отдельно для каждой операции.
Один контракт без трёх источников правды
Если quantity должно быть целым числом от 1 до 100, не хочется получить три версии правила:
OpenAPI 1..100
PHP Validator 1..1000
Frontend 1..50Полностью единственного источника не получается: бизнес-правило всё равно отражается в нескольких слоях. Но PHP-типы, validation rules, enum и OpenAPI можно держать рядом и проверять автоматически.
То же относится к path, query и headers. У GET /api/orders/{id} есть обязательный целочисленный id. У GET /api/orders?page=2&limit=50&status=new — границы пагинации и enum статуса. У запроса могут быть Accept-Language, Idempotency-Key и X-Request-ID. Отдельный документ чаще всего забывает именно эти детали.
OpenAPI быстро вскрывает и разнобой в пагинации. Если один список возвращает page, другой current_page, а третий прячет всё в pagination, проблема уже архитектурная, а не редакторская.
Bitrix без особого статуса
В Laravel и Symfony инфраструктура вокруг OpenAPI привычнее. В проекте на «1С-Битрикс: Управление сайтом» её может не быть совсем, особенно если собственное API выросло из файлов ajax.php. Стандарту всё равно, на каком фреймворке написан endpoint.
Я бы начал хотя бы с небольшого слоя внутри модуля:
/local/modules/project.api/
├── lib/Controller/
├── lib/Request/
├── lib/Response/
├── lib/Service/
├── lib/Exception/
└── lib/OpenApi/swagger-php сканирует эти классы и создаёт openapi.json, который затем открывает Swagger UI. Фреймворк здесь вторичен; важны явные request/response DTO и одно место для ошибок.
Bitrix24 — другой случай. Не нужно смешивать собственный API сайта с REST API продукта. Для методов REST 3.0 официальная документация показывает получение OpenAPI-описания через endpoint /rest/api/{user_id}/{rest_app_password}/documentation. Контракт собственного API поверх Bitrix Framework всё равно остаётся ответственностью команды проекта.
Минимальный чек-лист endpoint
Перед публикацией метода я хочу получить ответы на эти вопросы:
method, URL, назначение
authentication и permissions
path, query, headers
request body, required, types, formats
constraints и enums
success response и status
error responses и application codes
pagination, sorting, filtering
rate limits и idempotency
deprecated fields и API version
реальные примерыСписок не требует пятидесяти строк Attributes над каждым методом. Схемы можно вынести к DTO, часть данных извлечь автоматически, а при большом API держать спецификацию отдельно. Критерий один: изменение кода не должно оставлять старый контракт незамеченным.
Swagger как тест архитектуры
Главная польза Swagger для меня оказалась не в кнопке Execute. При попытке точно описать endpoint становятся видны неудобные вопросы: почему здесь 200, а рядом 201; откуда взялись два формата ошибки; почему статус — произвольная строка; почему одинаковая сущность возвращается тремя способами.
Swagger не создаёт этот разнобой. Он не даёт спрятать его внутри контроллеров.
Я пришёл к OpenAPI поздно: к 2022 году успел написать много PHP-кода и несколько API без формального контракта. Сейчас воспринимаю спецификацию как договор между backend, frontend, мобильным приложением и интеграциями — и как проверку решений самого backend-разработчика.
Остаётся неприятная часть: route, DTO, validation, enum и responses уже записаны в коде, а затем значительную часть приходится повторять в OpenAPI. В продолжении разбираю, что в этой работе можно делегировать AI и где ему нельзя позволять додумывать контракт.
А личную сторону этого перехода — почему такая синхронизация перестала казаться мне «просто частью программирования» — я описал в Дневнике Vibe-Разработчика.