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
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:
npm run openapi:bundleThis command:
- Reads
openapi/openapi-source.yaml(with$refto other files) - Resolves all references (domains, modules, schemas)
- Outputs single
openapi/openapi.yamlfile - Runs automatically on pre-commit hook
Pre-Commit Hook:
# .git/hooks/pre-commit
npm run openapi:bundle # Auto-bundles before commitThis 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:
- Developer updates OpenAPI spec
- Pre-commit hook bundles spec automatically
- Changes pushed to GitHub
- Postman automatically syncs from GitHub repository
- 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:
.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 testingImportant:
.postman/is auto-managed by Postman - don't edit manuallypostman/collections/contains the actual collections synced from OpenAPI specs- Changes to
openapi/openapi.yamltrigger automatic updates in Postman
Updating Collections from GitHub:
- Make changes to OpenAPI specs (e.g.,
modules/Vendor/OpenAPI/vendors.yaml) - Commit and push (bundling happens automatically)
- Postman detects changes via GitHub webhook
- Click Pull Changes in Postman to sync latest API
Local Development Workflow
For testing changes before committing:
Step 1: Bundle locally
npm run openapi:bundleStep 2: Import to Postman (local only)
- Open Postman
- Click Import button
- Select
openapi/openapi.yamlfrom your local repository - This creates a separate local collection for testing
Step 3: Configure Environment
{
"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
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 teamBenefits 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:
docker exec wedissimo-api php artisan make:module YourDomainThis creates:
modules/YourDomain/Http/Controllers/- API controllersmodules/YourDomain/Routes/api.php- API routes with v1 prefixmodules/YourDomain/Http/Resources/- API response transformersmodules/YourDomain/OpenAPI/- OpenAPI specificationsyour-domain.yaml- Main API spec (paths & operations)schemas/- Schema definitions (one file per schema)YourResource.yamlYourResourcePublic.yamlCreateYourResourceRequest.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
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
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
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:
// ✅ 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:
// 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:
# Create in module
php artisan make:request Modules/YourDomain/Http/Requests/StoreYourResourceRequestExample (modules/Vendor/Http/Requests/UpdateVendorRequest.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
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:
modules/YourDomain/Tests/
├── Feature/
│ └── Api/
│ └── YourDomainApiTest.php
└── Unit/Example: Vendor API Tests (modules/Vendor/Tests/Feature/Api/VendorSearchTest.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
# 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.phpSee Testing Guide for comprehensive testing patterns.
TypeScript Integration
Generating Types from OpenAPI
Module OpenAPI specs can be used to generate TypeScript types:
# 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.tsUsing Generated Types:
// 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:
# Edit schema in module
vim modules/Vendor/OpenAPI/schemas/Vendor.yaml
# Or edit paths in main spec
vim modules/Vendor/OpenAPI/vendors.yaml2. Bundle automatically (pre-commit hook) or manually:
npm run openapi:bundle3. Regenerate TypeScript types (if using):
npx openapi-typescript openapi/openapi.yaml -o src/types/api.ts4. 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:
# - 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
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:
- Vendor Domain - Vendor workflows
- Listing Domain - Listing management
Response Formats
Success Response (200/201)
{
"data": { ... },
"meta": {
"current_page": 1,
"per_page": 15,
"total": 72
}
}Validation Error (422)
{
"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)
{
"message": "Unauthenticated."
}Not Found (404)
{
"message": "Resource not found."
}Environment Configuration
# .env
API_URL=http://localhost:8000/api/v1
API_VERSION=v1
# For frontend
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1Testing Endpoints
Using cURL
# 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
# 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.comBest 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
Related Documentation
- Module System Guide - Creating and managing modules
- Domain Documentation - Domain-specific implementation details
- Authentication Guide - Auth strategies and patterns
- Testing Guide - API testing patterns
- Search Guide - Implementing search functionality