Building a Reliable REST API on Laravel with Automated Testing and Documentation in a CI/CD Pipeline
Introduction: Why API Testing and Documentation Are Critical in 2026
In 2026, a REST API is more than just a set of endpoints — it's a contract between teams, systems, and the business. A broken endpoint in production costs money and reputation. Outdated documentation forces frontend teams to spend hours debugging instead of building. CI/CD addresses both issues: every commit automatically runs through tests, generates up-to-date documentation, and only then reaches production.
Laravel remains one of the most popular PHP frameworks for building APIs, thanks to its built-in testing support, rich ecosystem, and expressive syntax. In this article, we'll walk through the entire journey: from project architecture to deploying documentation via GitHub Actions.
Project Architecture: Layers, Patterns, and Structure
A well-structured Laravel project is the foundation of testable code. We use the Repository and Service patterns to separate concerns.
A typical directory structure for an API project:
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
The service layer contains business logic, the repository handles database interaction. The controller stays thin — just accept the request, delegate, and return a response.
<?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);
}
}
This architecture allows swapping implementations in tests via Laravel's DI container without touching business logic.
Writing Feature Tests for a Laravel REST API
Laravel ships with PHPUnit out of the box. We also recommend installing Pest — a more expressive syntax with less boilerplate.
composer require pestphp/pest pestphp/pest-plugin-laravel --dev
./vendor/bin/pest --init
Factories and Fixtures
Model Factories are the foundation of isolated tests. Never use a shared database for tests — use the RefreshDatabase or DatabaseTransactions trait instead.
<?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 Test with Authorization (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 () {
// Reset authentication
$this->withoutMiddleware(\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class);
$this->getJson('/api/v1/products', ['Authorization' => ''])
->assertUnauthorized();
});
});
Measuring Code Coverage
Add coverage settings to phpunit.xml:
<coverage>
<include>
<directory suffix=".php">./app</directory>
</include>
<report>
<html outputDirectory="coverage-report"/>
<clover outputFile="coverage.xml"/>
</report>
</coverage>
Run with coverage: ./vendor/bin/pest --coverage --min=80 — the --min flag will fail the build if coverage drops below 80%.
Contract Testing for REST API
Contract testing ensures that the API conforms to an agreed-upon contract — the request and response structure expected by consumers. This is critical in a microservices architecture.
Two tools are well-suited for PHP/Laravel:
- Pact PHP (
pact-foundation/pact-php) — a fully Pact-compatible framework with Pact Broker support. - Spectator (
hotmeteor/spectator) — a simpler option: validates requests and responses against your OpenAPI file directly inside PHPUnit/Pest tests.
Example with 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);
});
If the response structure diverges from the api-v1.yaml schema, the test will fail. This eliminates the "documentation says one thing, API returns another" problem.
Auto-generating OpenAPI/Swagger Documentation from Code
Writing Swagger documentation by hand goes stale faster than the code itself. The solution is to generate documentation automatically from PHP annotations or attributes.
The darkaonline/l5-swagger Package
composer require darkaonline/l5-swagger
php artisan vendor:publish --provider="L5Swagger\L5SwaggerServiceProvider"
Annotate controllers using OpenApi attributes:
<?php
use OpenApi\Attributes as OA;
#[OA\Get(
path: '/api/v1/products',
summary: 'List of products',
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: 'Successful response',
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: 'Unauthorized')
]
)]
public function index(): AnonymousResourceCollection
{
return ProductResource::collection($this->service->getPaginated());
}
Generate the document: php artisan l5-swagger:generate. The result is a storage/api-docs/api-docs.json file that can be connected to Swagger UI or ReDoc.
Alternative: knuckleswtf/scribe
Scribe analyzes FormRequest classes, route annotations, and test traces to generate documentation with minimal annotations. It's a great choice if you want documentation quickly without detailed markup:
composer require knuckleswtf/scribe --dev
php artisan scribe:generate
CI/CD Integration: GitHub Actions
The entire cycle — tests, coverage, documentation generation, deployment — should run automatically on every push to the main branches.
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
Key highlights of this pipeline:
- The
testjob spins up PostgreSQL 16 and Redis 7 as GitHub Actions services — an isolated environment identical to production. - The
--min=80flag in Pest stops the pipeline if coverage falls below the threshold. - The
generate-docsjob runs only after tests pass successfully (needs: test) and only on themainbranch. - Documentation is automatically published to GitHub Pages.
Docker for the Test Environment
For local development and environment reproducibility, we use Docker. The docker-compose.testing.yml file:
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 — a minimal image for running tests:
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
Run tests locally: docker compose -f docker-compose.testing.yml up --abort-on-container-exit. This fully reproduces the CI environment on a developer's machine.
Practical Tips: What You Must Test
Always Test
- The happy path for every endpoint — valid data, expected status code, and response structure.
- Authorization and access control — an unauthenticated request should return 401, a request without the required role — 403.
- Input validation — missing required fields, invalid types, boundary values.
- Pagination — correctness of
meta.total,meta.per_pagemetadata, behavior on the last page. - Concurrent requests for critical operations — for example, double balance deduction.
- Response conformance to the OpenAPI schema via Spectator.
Can Skip or Defer
- Testing third-party SDKs and libraries — they are already tested by their authors.
- Trivial getters/setters on models with no logic.
- Complex UI scenarios unrelated to the API contract.
Practical Rules
- One test — one scenario. Don't test both creation and deletion in a single test.
- Use
assertJsonPath()instead ofassertJson()for targeted assertions without locking into the full response structure. - Mock external HTTP requests via
Http::fake()— tests must not depend on the network. - Add a test for every bug found before fixing it — this prevents regressions.
Conclusion and Checklist for a Production-Ready API
A production-ready Laravel REST API in 2026 is more than working code. It's a predictable contract, automatically verified with every change. CI/CD unifies testing, documentation, and deployment into a single automated process that removes the human factor from critical operations.
Code without tests is code you're afraid to touch. Documentation without auto-generation is documentation nobody trusts.
Production-Ready Laravel API Checklist
- Architecture is separated into layers: Controller → Service → Repository → Model.
- All public endpoints are covered by Feature tests (happy path + edge cases).
- Authorization is tested: 401 for unauthenticated requests, 403 for forbidden actions.
- Validation is verified with invalid data and correct error codes.
- Contract tests via Spectator validate responses against the OpenAPI schema.
- Swagger/OpenAPI documentation is generated automatically from annotations.
- GitHub Actions runs tests on every PR and push to
main. - Code coverage is at least 80%, with the minimum threshold enforced in CI.
- Docker isolates the test environment with real PostgreSQL and Redis instances.
- Documentation is automatically deployed when merged into
main. - External HTTP requests are mocked via
Http::fake(). - Every bug found is accompanied by a regression test.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →