Backend development

Building a Reliable REST API on Laravel with Automated Testing and Documentation in a CI/CD Pipeline

Ruslan Ismailov Published 18 min read
B

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 test job spins up PostgreSQL 16 and Redis 7 as GitHub Actions services — an isolated environment identical to production.
  • The --min=80 flag in Pest stops the pipeline if coverage falls below the threshold.
  • The generate-docs job runs only after tests pass successfully (needs: test) and only on the main branch.
  • 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_page metadata, 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 of assertJson() 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

  1. Architecture is separated into layers: Controller → Service → Repository → Model.
  2. All public endpoints are covered by Feature tests (happy path + edge cases).
  3. Authorization is tested: 401 for unauthenticated requests, 403 for forbidden actions.
  4. Validation is verified with invalid data and correct error codes.
  5. Contract tests via Spectator validate responses against the OpenAPI schema.
  6. Swagger/OpenAPI documentation is generated automatically from annotations.
  7. GitHub Actions runs tests on every PR and push to main.
  8. Code coverage is at least 80%, with the minimum threshold enforced in CI.
  9. Docker isolates the test environment with real PostgreSQL and Redis instances.
  10. Documentation is automatically deployed when merged into main.
  11. External HTTP requests are mocked via Http::fake().
  12. 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 →