Skip to content

API Documentation

Architecture decision

Our API documentation and reference workflow is described in ADR 0003: API documentation & OpenAPI strategy.

Architecture: Modular Monolith with Domain-Driven Design Version: v1 API Base: /api/v1


Overview

Wedissimo's API is organized by business domains, with each domain implemented as a module. The API follows RESTful conventions and uses OpenAPI 3.1.0 specifications for documentation.

Key Characteristics

  • Domain-Driven: Endpoints organized by business domain (Vendors, Listings, Users)
  • Modular Structure: Domain logic contained in modules (modules/*/Http/Controllers)
  • Versioned: All endpoints prefixed with /api/v1
  • Type-Safe: OpenAPI specs enable TypeScript generation
  • Authenticated: Laravel Sanctum for API tokens, Session-based for admin
  • Documented: Comprehensive OpenAPI specs for automated tooling

OpenAPI Specification

Why We Use OpenAPI 3.1

The entire API is documented using OpenAPI 3.1.0 (formerly Swagger) specifications. This provides significant benefits:

1. Postman Integration**

  • Automatic Import: Import OpenAPI specs directly into Postman
  • Auto-Generated Collections: All endpoints, parameters, and examples instantly available
  • Sync on Changes: Re-import updated specs to get latest API changes
  • Mock Servers: Generate mock responses for frontend development
  • Request Examples: Pre-filled request bodies and parameters

2. Type-Safe Client Generation

  • TypeScript: Generate type-safe API clients for React/Next.js
  • PHP: Generate SDK for external integrations
  • Other Languages: Support for Java, Python, Go, Ruby, etc.

3. Interactive Documentation

  • API Explorer: Test endpoints directly from documentation
  • Live Examples: Real request/response examples
  • Schema Validation: Automatic validation of requests against spec

4. Developer Experience

  • Single Source of Truth: API contract defined in OpenAPI
  • Contract-First Development: Design API before implementation
  • Automated Testing: Generate tests from spec
  • Version Control: Track API changes in git

5. Team Collaboration

  • Frontend/Backend Alignment: Shared API contract
  • Client Communication: Easy to share API documentation
  • Onboarding: New developers see complete API in one place

OpenAPI File Structure

bash
openapi/
├── openapi-source.yaml          # Main spec (references all domains)
├── openapi.yaml                 # Bundled output (committed for Postman)
├── common/
   ├── spec.yaml               # Shared components (errors, pagination)
   └── schemas/                 # Shared schemas
       ├── Media.yaml           # Common media schema
       ├── Category.yaml        # Shared categories
       └── Tag.yaml             # Shared tags
├── domains/
   ├── users.yaml               # User & auth endpoints
└── modules/
    └── Vendor/OpenAPI/
        ├── vendors.yaml         # Vendor endpoints
        └── schemas/             # Vendor-specific schemas
            ├── Vendor.yaml
            ├── VendorPublic.yaml
            └── ...

Bundling with Redocly

We use Redocly CLI to bundle modular OpenAPI specs into a single file:

Why Bundling?

  • Postman Compatibility: Postman works best with single-file specs
  • Performance: Faster loading than multiple file references
  • Distribution: Easy to share complete API spec
  • CI/CD: Automated bundling ensures spec is always up-to-date

Bundle Command:

bash
npm run openapi:bundle

This command:

  1. Reads openapi/openapi-source.yaml (with $ref to other files)
  2. Resolves all references (domains, modules, schemas)
  3. Outputs single openapi/openapi.yaml file
  4. Runs automatically on pre-commit hook

Pre-Commit Hook:

bash
# .git/hooks/pre-commit
npm run openapi:bundle  # Auto-bundles before commit

This ensures the bundled spec is always synchronized with source files.

Postman Integration

We use Postman's GitHub Integration for automatic API synchronization.

Production Workflow (GitHub Integration)

Automatic Sync:

  1. Developer updates OpenAPI spec
  2. Pre-commit hook bundles spec automatically
  3. Changes pushed to GitHub
  4. Postman automatically syncs from GitHub repository
  5. Team members get updated collections in Postman client

GitHub Connection:

  • Repository: Connected via .postman/ directory
  • Collections stored in: postman/collections/
  • OpenAPI source: openapi/openapi.yaml (auto-bundled)

Repository Structure:

bash
.postman/                           # Auto-generated by Postman (DO NOT EDIT)
├── api                             # API connection config
└── api_*                           # API version tracking

postman/
├── collections/
   └── openapi-collection.json    # Generated from OpenAPI spec
├── dev-collection.json             # Development environment (legacy)
└── ci-collection.json              # CI/CD testing

Important:

  • .postman/ is auto-managed by Postman - don't edit manually
  • postman/collections/ contains the actual collections synced from OpenAPI specs
  • Changes to openapi/openapi.yaml trigger automatic updates in Postman

Updating Collections from GitHub:

  1. Make changes to OpenAPI specs (e.g., modules/Vendor/OpenAPI/vendors.yaml)
  2. Commit and push (bundling happens automatically)
  3. Postman detects changes via GitHub webhook
  4. Click Pull Changes in Postman to sync latest API

Local Development Workflow

For testing changes before committing:

Step 1: Bundle locally

bash
npm run openapi:bundle

Step 2: Import to Postman (local only)

  1. Open Postman
  2. Click Import button
  3. Select openapi/openapi.yaml from your local repository
  4. This creates a separate local collection for testing

Step 3: Configure Environment

json
{
    "API_URL": "http://localhost:2222/api/v1",
    "AUTH_TOKEN": "{{auth_token}}"
}

Step 4: Test Locally

  • All request bodies pre-filled with examples
  • All parameters documented
  • Response schemas validated

Step 5: Push to update production

bash
git add openapi/openapi.yaml modules/Vendor/OpenAPI/vendors.yaml
git commit -m "feat(api): add new vendor endpoints"
git push
# Postman automatically syncs for entire team

Benefits of GitHub Integration

  • Automatic Updates - No manual import needed
  • Version Control - API changes tracked in git
  • Team Sync - Everyone gets updates automatically
  • Change History - See what changed and when
  • Rollback Support - Revert to previous API versions
  • CI/CD Ready - Collections can be used in automated tests


Creating Module APIs

When creating a new module with API endpoints, use the make:module command which generates the structure:

bash
docker exec wedissimo-api php artisan make:module YourDomain

This creates:

  • modules/YourDomain/Http/Controllers/ - API controllers
  • modules/YourDomain/Routes/api.php - API routes with v1 prefix
  • modules/YourDomain/Http/Resources/ - API response transformers
  • modules/YourDomain/OpenAPI/ - OpenAPI specifications
    • your-domain.yaml - Main API spec (paths & operations)
    • schemas/ - Schema definitions (one file per schema)
      • YourResource.yaml
      • YourResourcePublic.yaml
      • CreateYourResourceRequest.yaml
      • etc.

Schema Organization Best Practice:

  • Keep main API file focused on paths/operations
  • Extract all schemas to separate files in schemas/ directory
  • Reference shared schemas from openapi/common/schemas/ (Media, Category, Tag)
  • This improves maintainability and follows OpenAPI best practices

See Module System Guide for complete details.


API Development Patterns

Controllers in Modules

Module API Routes (modules/YourDomain/Routes/api.php):

php
<?php

use Illuminate\Support\Facades\Route;
use Modules\YourDomain\Http\Controllers\YourDomainController;

Route::prefix('api/v1')->middleware(['api'])->group(function () {
    Route::prefix('your-resources')->group(function () {
        // Search endpoint (often public)
        Route::get('/search', [YourDomainController::class, 'search'])
            ->name('your-resources.search');

        // Resource CRUD (authenticated)
        Route::middleware(['auth:sanctum'])->group(function () {
            Route::get('/', [YourDomainController::class, 'index'])
                ->name('your-resources.index');
            Route::post('/', [YourDomainController::class, 'store'])
                ->name('your-resources.store');
            Route::get('/{id}', [YourDomainController::class, 'show'])
                ->name('your-resources.show');
            Route::patch('/{id}', [YourDomainController::class, 'update'])
                ->name('your-resources.update');
            Route::delete('/{id}', [YourDomainController::class, 'destroy'])
                ->name('your-resources.destroy');
        });
    });
});

Example: Listing Module Routes (modules/Listing/Routes/api.php):

php
<?php

use Illuminate\Support\Facades\Route;
use Modules\Listing\Http\Controllers\CategoryController;
use Modules\Listing\Http\Controllers\ListingController;
use Modules\Listing\Http\Controllers\TagController;

Route::prefix('api/v1')->middleware(['api'])->group(function () {
    Route::prefix('listings')->group(function () {
        // Category routes
        Route::prefix('categories')->group(function () {
            Route::get('/', [CategoryController::class, 'index'])
                ->name('categories.index');
            Route::get('/autocomplete', [CategoryController::class, 'autocomplete'])
                ->name('categories.autocomplete');
        });

        // Tag routes
        Route::prefix('tags')->group(function () {
            Route::get('/autocomplete', [TagController::class, 'autocomplete'])
                ->name('tags.autocomplete');
        });

        // Listing search and detail routes
        Route::get('/search', [ListingController::class, 'search'])
            ->name('listings.search');
        Route::get('/{slugOrId}/gallery', [ListingController::class, 'gallery'])
            ->name('listings.gallery');
        Route::get('/{slugOrId}', [ListingController::class, 'show'])
            ->name('listings.show');
    });
});

Example: Vendor Module Routes (modules/Vendor/Routes/api.php):

php
<?php

use Illuminate\Support\Facades\Route;
use Modules\Vendor\Http\Controllers\VendorController;
use Modules\Vendor\Http\Controllers\ServiceTypeController;

Route::prefix('api/v1')->middleware(['api'])->group(function () {
    Route::prefix('vendors')->group(function () {
        // Public search
        Route::get('/search', [VendorController::class, 'search'])
            ->name('vendors.search');

        // Service types
        Route::prefix('service-types')->group(function () {
            Route::get('/', [ServiceTypeController::class, 'index'])
                ->name('service-types.index');
            Route::get('/{slug}', [ServiceTypeController::class, 'show'])
                ->name('service-types.show');
        });

        // Authenticated vendor endpoints
        Route::middleware(['auth:sanctum'])->prefix('me')->group(function () {
            Route::patch('/listings/{listingId}/gallery/{mediaId}', [ListingController::class, 'updateGalleryMedia'])
                ->name('vendors.me.listings.gallery.update')
                ->where([
                    'listingId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}',
                    'mediaId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}',
                ]);
        });

        // Flexible ID/slug routes (accepts both UUID and slug) - must be last
        Route::get('/{slugOrId}/listings', [VendorController::class, 'listings'])
            ->name('vendors.listings');
        Route::get('/{slugOrId}', [VendorController::class, 'show'])
            ->name('vendors.show');
    });
});

Named Routes Best Practices

ALWAYS use named routes with the ->name() method:

php
// ✅ CORRECT - Named route
Route::get('/vendors/{id}', [VendorController::class, 'show'])
    ->name('vendors.show');

// ❌ WRONG - Unnamed route
Route::get('/vendors/{id}', [VendorController::class, 'show']);

Benefits of Named Routes:

  • Refactoring Safety: Change URLs without breaking redirects/links
  • URL Generation: Use route('vendors.show', $id) instead of hardcoded paths
  • Testing: Reference routes by name in tests
  • Documentation: Clear route identification in php artisan route:list

Naming Convention:

  • Use dot notation: resource.action
  • Nested resources: parent.child.action
  • Authenticated user endpoints: resource.me.action

Examples:

php
// Simple resource
->name('vendors.index')
->name('vendors.show')
->name('vendors.update')

// Nested resource
->name('listings.categories.index')
->name('listings.tags.autocomplete')

// Authenticated user endpoints
->name('vendors.me.listings.gallery.update')
->name('users.me.update')

Form Request Validation

Create Form Requests in the module directory:

bash
# Create in module
php artisan make:request Modules/YourDomain/Http/Requests/StoreYourResourceRequest

Example (modules/Vendor/Http/Requests/UpdateVendorRequest.php):

php
<?php

namespace Modules\Vendor\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UpdateVendorRequest extends FormRequest
{

    public function rules(): array
    {
        return [
            'business_name' => 'sometimes|string|max:255',
            'description' => 'sometimes|string|max:2000',
            'city' => 'sometimes|string|max:100',
            'country' => 'sometimes|string|max:100',
            'years_of_experience' => 'sometimes|integer|min:0',
        ];
    }
}

API Resources

Use Eloquent API Resources for consistent response formatting:

Example (modules/Vendor/Http/Resources/VendorResource.php):

php
<?php

namespace Modules\Vendor\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class VendorResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'business_name' => $this->business_name,
            'description' => $this->description,
            'location' => [
                'city' => $this->city,
                'country' => $this->country,
                'latitude' => $this->latitude,
                'longitude' => $this->longitude,
            ],
            'rating' => $this->rating,
            'review_count' => $this->review_count,
            'verified' => (bool) $this->verified,
            'categories' => $this->categories->pluck('name'),
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
}

Testing APIs

Module API Tests

Place tests in the module's test directory:

bash
modules/YourDomain/Tests/
├── Feature/
   └── Api/
       └── YourDomainApiTest.php
└── Unit/

Example: Vendor API Tests (modules/Vendor/Tests/Feature/Api/VendorSearchTest.php):

php
<?php

use Modules\Vendor\Models\Vendor;

test('can search vendors', function () {
    Vendor::factory()->count(5)->create();

    $response = $this->getJson(route('vendors.search', ['query' => 'photographer']));

    $response->assertSuccessful()
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'name', 'business_name', 'rating'],
            ],
            'meta' => ['current_page', 'total'],
        ]);
});

test('can view vendor details', function () {
    $vendor = Vendor::factory()->create();

    $response = $this->getJson(route('vendors.show', $vendor->slug));

    $response->assertSuccessful()
        ->assertJsonStructure([
            'data' => ['id', 'name', 'business_name', 'description', 'location'],
        ]);
});

test('can view vendor listings', function () {
    $vendor = Vendor::factory()->create();

    $response = $this->getJson(route('vendors.listings', $vendor->slug));

    $response->assertSuccessful()
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'title', 'description'],
            ],
        ]);
});

Benefits of Using Named Routes in Tests:

  • Refactoring Safety: Route URL changes don't break tests
  • Type Safety: IDE autocomplete for route names
  • Clarity: Clear intent of what endpoint is being tested
  • Consistency: Matches how routes are used in controllers

Running Module Tests

bash
# All module tests
docker exec wedissimo-api vendor/bin/pest modules/Vendor/Tests/

# Specific test file
docker exec wedissimo-api vendor/bin/pest modules/Vendor/Tests/Feature/Api/VendorSearchTest.php

See Testing Guide for comprehensive testing patterns.


TypeScript Integration

Generating Types from OpenAPI

Module OpenAPI specs can be used to generate TypeScript types:

bash
# Install openapi-typescript
npm install -D openapi-typescript

# Generate types from bundled spec (recommended)
npx openapi-typescript openapi/openapi.yaml -o src/types/api.ts

# Or generate from specific module
npx openapi-typescript modules/Vendor/OpenAPI/vendors.yaml -o src/types/vendor-api.ts

Using Generated Types:

typescript
// src/types/api.ts (auto-generated from OpenAPI)
import type { paths, components } from "./api";

type VendorResponse = components["schemas"]["Vendor"];
type GetVendorParams = paths["/vendors/{id}"]["get"]["parameters"];

// Type-safe API client
async function getVendor(id: string): Promise<VendorResponse> {
    const response = await fetch(`/api/v1/vendors/${id}`);
    return response.json();
}

This enables type-safe API clients in React/Next.js applications.

Updating OpenAPI Specs

When adding or modifying endpoints:

1. Update the schema files:

bash
# Edit schema in module
vim modules/Vendor/OpenAPI/schemas/Vendor.yaml

# Or edit paths in main spec
vim modules/Vendor/OpenAPI/vendors.yaml

2. Bundle automatically (pre-commit hook) or manually:

bash
npm run openapi:bundle

3. Regenerate TypeScript types (if using):

bash
npx openapi-typescript openapi/openapi.yaml -o src/types/api.ts

4. Import updated spec to Postman:

  • Postman → Import → openapi/openapi.yaml

See domain-specific documentation for complete API client examples.


Key Concepts

HTTP Methods: PUT vs PATCH

Important Design Decision:

This API uses PATCH for all update operations to enable partial updates:

bash
# - PATCH - Update only the fields you send (recommended)
PATCH /api/v1/users/me
{
  "phone": "+447700900456"  # Only update phone
}

# ❌ PUT - Would require ALL fields to be sent (not used in this API)
PUT /api/v1/users/me
{
  "first_name": "Sarah",
  "last_name": "Johnson",
  "phone": "+447700900456",
  "email": "sarah@example.com",
  # ... all other required fields
}

Benefits:

  • Simpler frontend code (only send changed fields)
  • Reduced bandwidth
  • Less chance of accidental data overwrites
  • Better UX (partial form submissions)

Exception: POST is still used for creating new resources

Authentication Flow

bash
1. Register POST /api/v1/register

2. Receive access_token (Laravel Sanctum)

3. Store token in localStorage/cookie

4. Include in subsequent requests:
   Authorization: Bearer {access_token}

5. Refresh token POST /api/v1/refresh

6. Logout POST /api/v1/logout (revokes token)

Search Implementation

See the Search Guide for details on implementing full-text search with Typesense, including:

  • Geographic radius search
  • Category and tag filtering
  • Price range filters
  • Faceted filtering

See domain-specific documentation for flow details:


Response Formats

Success Response (200/201)

json
{
  "data": { ... },
  "meta": {
    "current_page": 1,
    "per_page": 15,
    "total": 72
  }
}

Validation Error (422)

json
{
    "message": "The given data was invalid.",
    "errors": {
        "email": [
            "The email field is required.",
            "The email must be a valid email address."
        ],
        "password": ["The password must be at least 8 characters."]
    }
}

Authentication Error (401)

json
{
    "message": "Unauthenticated."
}

Not Found (404)

json
{
    "message": "Resource not found."
}

Environment Configuration

bash
# .env
API_URL=http://localhost:8000/api/v1
API_VERSION=v1

# For frontend
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1

Testing Endpoints

Using cURL

bash
# Register customer
curl -X POST http://localhost:8000/api/v1/register \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Sarah",
    "last_name": "Johnson",
    "email": "sarah@example.com",
    "password": "SecureP@ssw0rd",
    "password_confirmation": "SecureP@ssw0rd",
    "consents": {
      "terms": true,
      "privacy": true,
      "marketing": false
    }
  }'

# Login
TOKEN=$(curl -X POST http://localhost:8000/api/v1/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "sarah@example.com",
    "password": "SecureP@ssw0rd"
  }' | jq -r '.access_token')

# Get user profile
curl -X GET http://localhost:8000/api/v1/users/me \
  -H "Authorization: Bearer $TOKEN"

# Search listings
curl -X GET "http://localhost:8000/api/v1/listings?category=photographers&location=London&radius=20"

Validation Rules

Common Patterns

yaml
# Email validation
email:
    type: string
    format: email
    example: user@example.com

# Password validation
password:
    type: string
    format: password
    minLength: 8
    example: SecureP@ssw0rd

# UUID validation
id:
    type: string
    format: uuid
    example: 9a7b8c5d-1234-5678-90ab-cdef12345678

# Phone validation
phone:
    type: string
    pattern: '^\+?[1-9]\d{1,14}$' # E.164 format
    example: "+447700900123"

# Date validation
wedding_date:
    type: string
    format: date
    example: "2026-08-15"

# URL validation
website:
    type: string
    format: uri
    example: https://example.com

Best Practices

API Controllers

  • Keep controllers thin - Business logic belongs in Service classes
  • Use API Resources - Always transform responses with Eloquent API Resources
  • Validate with Form Requests - Never inline validation rules
  • Return appropriate status codes - 200 (OK), 201 (Created), 204 (No Content), 404 (Not Found), 422 (Validation Error)

Module Organization

  • Group by domain - Keep all domain logic (controllers, requests, resources) in the module
  • Use module namespaces - Modules\YourDomain\Http\Controllers
  • Document with OpenAPI - Maintain OpenAPI spec in module directory
  • Test at module level - Place tests in modules/YourDomain/Tests/

Security

  • Always use Form Requests for validation
  • Use Sanctum tokens for API authentication
  • Apply rate limiting to public endpoints
  • Validate and sanitize all inputs
  • Use HTTPS in production

Wedissimo API Documentation