Прямая привязка внешнего API к структуре таблиц базы данных — одна из самых частых причин сбоев в мобильных приложениях и сторонних интеграциях. Достаточно переименовать колонку в миграции или изменить тип данных, как клиентские приложения, завязанные на прежнюю схему, перестают корректно парсить ответы.
Проектирование надежного REST API требует четкого контракта: изоляции внутренней бизнес-логики от публичных JSON-ответов, явного версионирования эндпоинтов, предсказуемой пагинации и автоматизированного контрактного тестирования. Ниже подробно разобран процесс построения такого API на примере каталога товаров в Laravel.
Архитектура версионирования и структура маршрутов /v1
Версионирование позволяет развивать бэкенд без риска нарушить работу клиентов, которые еще не обновили мобильные сборки или внешние интеграции. Наиболее прозрачный и распространенный подход — версионирование через префикс пути в URI (/api/v1/...).
Изоляция версий: пространства имен и контроллеры
Каждая мажорная версия API должна существовать изолированно. Для этого контроллеры, ресурсы и классы запросов разносятся по отдельным пространствам имен:
app/
├── Http/
│ ├── Controllers/
│ │ └── Api/
│ │ ├── V1/
│ │ │ └── ProductController.php
│ │ └── V2/
│ │ └── ProductController.php
│ └── Resources/
│ ├── V1/
│ │ └── ProductResource.php
│ └── V2/
│ └── ProductResource.php
В файле routes/api.php маршруты группируются с указанием соответствующего префикса и пространства имен:
use App\Http\Controllers\Api\V1\ProductController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->name('api.v1.')->group(function () {
Route::apiResource('products', ProductController::class);
});
Проектирование REST-эндпоинтов каталога товаров
Контракт для управления каталогом товаров /v1/products опирается на стандартные HTTP-методы и статус-коды:
GET /api/v1/products— получение пагинированного списка товаров (HTTP 200 OK);GET /api/v1/products/{id}— просмотр детальной карточки конкретного товара (HTTP 200 OK или 404 Not Found);POST /api/v1/products— создание нового товара (HTTP 201 Created);PATCH /api/v1/products/{id}— частичное обновление полей товара (HTTP 200 OK);DELETE /api/v1/products/{id}— удаление позиции (HTTP 204 No Content).
Слой трансформации: изоляция моделей через JsonResource
Прямой возврат Eloquent-моделей из контроллера (return Product::all();) нарушает инкапсуляцию. При таком подходе в публичный ответ попадают внутренние поля базы данных (например, deleted_at, технические внешние ключи supplier_internal_id или системные флаги), а формат данных оказывается жестко связан со схемой СУБД.
Реализация ProductResource и строгая типизация полей
Слой Illuminate\Http\Resources\Json\JsonResource выступает адаптером между моделью и сериализованным JSON. Метод toArray() определяет точную форму контракта:
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductResource extends JsonResource
{
/**
* Преобразование ресурса в массив.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => (int) $this->id,
'sku' => (string) $this->sku,
'name' => (string) $this->name,
'price' => number_format((float) $this->price, 2, '.', ''),
'currency' => (string) ($this->currency ?? 'RUB'),
'is_available' => (bool) ($this->stock_quantity > 0),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}
Явное приведение типов ((int), (string), (bool)) защищает контракт от особенностей драйверов баз данных, когда числовые значения могут возвращаться как строки или наоборот.
Управление корневой оберткой data
По умолчанию Laravel оборачивает возвращаемый ресурс в верхнеуровневый ключ data. Это соответствует базовым рекомендациям спецификации JSON:API:
{
"data": {
"id": 101,
"sku": "ITEM-WIDGET-01",
"name": "Набор инструментов",
"price": "4990.00",
"currency": "RUB",
"is_available": true,
"created_at": "2024-03-15T10:30:00+00:00"
}
}
Если по требованиям клиента корневая обертка для одиночных ресурсов не нужна, ее можно отключить глобально в методе boot сервиса AppServiceProvider вызовом JsonResource::withoutWrapping(). Однако сохранение единой структуры с ключом data делает парсинг ответов предсказуемым для всех эндпоинтов.
Стабильный формат пагинации для списков
Для эндпоинтов со списками сущностей недопустимо отдавать весь массив записей целиком. Выборку необходимо ограничивать встроенным пагинатором Laravel, передавая результат в ресурсную коллекцию.
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\V1\ProductResource;
use App\Models\Product;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ProductController extends Controller
{
public function index(): AnonymousResourceCollection
{
$products = Product::query()
->where('is_active', true)
->orderBy('id', 'desc')
->paginate(15);
return ProductResource::collection($products);
}
}
Структура пагинированного ответа: data, links и meta
При передаче LengthAwarePaginator в ProductResource::collection() Laravel автоматически формирует JSON-ответ с метаданными и ссылками:
{
"data": [
{
"id": 101,
"sku": "ITEM-WIDGET-01",
"name": "Набор инструментов",
"price": "4990.00",
"currency": "RUB",
"is_available": true,
"created_at": "2024-03-15T10:30:00+00:00"
}
],
"links": {
"first": "https://api.example.com/v1/products?page=1",
"last": "https://api.example.com/v1/products?page=8",
"prev": null,
"next": "https://api.example.com/v1/products?page=2"
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 8,
"path": "https://api.example.com/v1/products",
"per_page": 15,
"to": 15,
"total": 120
}
}
Клиентам рекомендуется переходить по готовым URL из блока links (в частности, next и prev), а не формировать строки запросов вручную на своей стороне.
Кастомизация метаданных через paginationInformation
Если требуется изменить состав или названия служебных блоков, в кастомном классе коллекции (ResourceCollection) можно переопределить метод paginationInformation:
namespace App\Http\Resources\V1;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ProductCollection extends ResourceCollection
{
public $collects = ProductResource::class;
/**
* Настройка формата пагинации.
*/
public function paginationInformation($request, $paginated, $default): array
{
return [
'pagination' => [
'current' => $default['meta']['current_page'],
'total_pages' => $default['meta']['last_page'],
'per_page' => $default['meta']['per_page'],
'total_items' => $default['meta']['total'],
],
'links' => [
'next' => $default['links']['next'],
'prev' => $default['links']['prev'],
],
];
}
}
Эволюция полей и правила обратной совместимости
Главная цель контракта — гарантировать, что релиз новых доработок бэкенда не приведет к сбоям в уже выпущенных версиях приложений.
Что считается нарушающим изменением (Breaking Change)
В рамках одной мажорной версии (/v1) недопустимо:
- Удалять существующие ключи из JSON-ответа.
- Переименовывать ключи (например, заменять
is_availableнаin_stock). - Менять тип данных поля (например, отдавать число
100вместо строки"100.00"или заменять скаляр объектом). - Менять структуру корневых блоков (
data,meta,links). - Делать ранее опциональные параметры входящего запроса строго обязательными.
Стратегия безопасного расширения и депрекация
Все изменения в рамках /v1 должны быть аддитивными:
- Добавление новых полей: клиенты со стандартным JSON-парсером игнорируют неизвестные им новые ключи.
- Дублирование при переименовании: если бизнес-логика требует нового названия поля, старое поле сохраняется параллельно до полного вывода версии из эксплуатации:
public function toArray(Request $request): array
{
return [
'id' => (int) $this->id,
'name' => (string) $this->name,
// Новое поле для обновленных клиентов
'title' => (string) $this->name,
// Устаревшее поле сохранено для совместимости
'legacy_title' => (string) $this->name,
];
}
- Переход на
/v2: если требуется комплексная смена структуры сущностей, отказ от устаревших полей или переход на другой формат пагинации (например, cursor-based вместо offset), создается отдельный набор маршрутов и контроллеров под префиксом/v2.
Контрактное тестирование API в Laravel
Автоматические тесты фиксируют схему ответа и предотвращают регрессии при изменении моделей или миграций.
Валидация структуры ответа через assertJsonStructure
Метод assertJsonStructure проверяет наличие всех обязательных ключей и их иерархию без привязки к конкретным значениям:
namespace Tests\Feature\Api\V1;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProductApiTest extends TestCase
{
use RefreshDatabase;
public function test_can_get_paginated_products_list_with_valid_contract(): void
{
Product::factory()->count(20)->create();
$response = $this->getJson('/api/v1/products');
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'*' => [
'id',
'sku',
'name',
'price',
'currency',
'is_available',
'created_at',
],
],
'links' => [
'first',
'last',
'prev',
'next',
],
'meta' => [
'current_page',
'from',
'last_page',
'path',
'per_page',
'to',
'total',
],
]);
}
public function test_single_product_endpoint_contract(): void
{
$product = Product::factory()->create([
'price' => 1500.50,
'stock_quantity' => 10,
]);
$response = $this->getJson("/api/v1/products/{$product->id}");
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'id',
'sku',
'name',
'price',
'currency',
'is_available',
'created_at',
],
])
->assertJsonPath('data.price', '1500.50')
->assertJsonPath('data.is_available', true);
}
}
Чек-лист стабильного REST API на Laravel
Перед выводом эндпоинтов в эксплуатацию стоит проверить проект по следующим критериям:
| Критерий | Проверка |
|---|---|
| Изоляция данных | Модели Eloquent не возвращаются напрямую; используется JsonResource или ResourceCollection. |
| Строгая типизация | В методе toArray() все поля приведены к явным скалярным типам. |
| Версионирование маршрутов | Все эндпоинты сгруппированы под явным префиксом версии (/api/v1/). |
| Формат списков | Методы списков используют paginate() или cursorPaginate() со стандартными блоками data, links, meta. |
| Независимость от БД | Изменения колонок в миграциях не меняют ключи в JSON-ответе. |
| Контрактные тесты | Для каждого публичного ресурса написаны тесты с вызовом assertJsonStructure. |
FAQ
1. Почему версионирование через URI предпочтительнее заголовков Accept?
Версионирование через URI (/api/v1/) прозрачно, упрощает кэширование на уровне HTTP-прокси (Nginx, CDN), облегчает ручную отладку и исключает ошибки конфигурации заголовков на стороне мобильных клиентов.
2. Как скрыть чувствительные поля при использовании API Resources?
В JsonResource не требуется скрывать поля через свойства модели вроде $hidden — достаточно не включать их в массив метода toArray(). В ответ попадут исключительно те ключи, которые объявлены в ресурсе.
3. Чем assertJsonStructure отличается от assertJson?
assertJson проверяет точные значения данных, а assertJsonStructure валидирует только схему ключей и их вложенность. Это позволяет тестировать контракт без жесткой привязки к динамическим полям (идентификаторам, датам).
4. Стоит ли использовать Cursor Pagination вместо стандартной пагинации?
Для мобильных лент с бесконечной прокруткой и таблиц с большим объемом данных предпочтительнее cursorPaginate(). Этот метод работает быстрее на крупных выборках, так как не выполняет подсчет общего количества строк (COUNT(*)), хотя и не возвращает общее число страниц в блоке meta.
5. Что делать, если изменился тип данных поля в новой версии?
Если поле price ранее отдавалось строкой "100.00", а теперь должно возвращаться объектом { "amount": 10000, "currency": "RUB" }, это нарушающее изменение. Его следует внедрять только в новой версии API (/v2/products), сохраняя /v1 в неизменном виде.
Заключение
Стабильный API — это результат дисциплины при проектировании контрактов. Изоляция схемы базы данных с помощью JsonResource, структурированная пагинация, явное версионирование через префиксы URI и автоматизированные тесты структуры позволяют развивать бэкенд независимо от графика обновлений клиентских приложений. Такой подход предотвращает сбои на продакшене и существенно упрощает долговременную поддержку проекта.
Источники
- Eloquent: API Resources | Laravel 7.x - The clean stack for Artisans and agents
- Eloquent: API Resources | Laravel 12.x - The clean stack for ...
- Laravel
- api.laravel.com › PaginationServiceProviderIlluminate\Pagination\PaginationServiceProvider | Laravel API
- Illuminate\Http\Resources\Json\PaginatedResourceResponse
- Illuminate\Http\Resources\Json\PaginatedResourceResponse



.svg.webp)





