Skip to main content
Laravel, shipping fast.

Chapter 9

Developer Experience

Julian Beaujardin

Your API is used by other developers. They're going to curse you if it's hard to use. Make your API pleasant to work with.

Good developer experience means:

  1. Clear documentation: Obvious endpoints, clear examples
  2. Consistency: Predictable behavior
  3. Good errors: Errors that tell you what went wrong and how to fix it
  4. Tooling support: Postman collections, OpenAPI specs, SDKs
  5. Fast feedback: Clear request/response examples
  6. Versioning: Plan for API changes without breaking users

This chapter is about being a good API citizen. Your users should enjoy using your API.

Environment Configuration

Document all environment variables:

# .env.example
# Application
APP_NAME="Domain API"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://api.example.com

# Database
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=api_domain
DB_USERNAME=root
DB_PASSWORD=password

# Cache
CACHE_DRIVER=redis
REDIS_HOST=localhost
REDIS_PORT=6379

# Queue
QUEUE_CONNECTION=redis

# External Services
GODADDY_API_KEY=your-key
GODADDY_API_SECRET=your-secret
GODADDY_ENVIRONMENT=production

# Logging
LOG_CHANNEL=stack
LOG_LEVEL=debug

# Localization
APP_LOCALE=en
APP_FALLBACK_LOCALE=en

Type Hints & Documentation

Use PHPDoc for clarity:

/**
 * Create a DNS record for a domain.
 *
 * @param  string  $domain    FQDN (e.g., 'example.com')
 * @param  string  $ip        IP address (IPv4 or IPv6)
 * @param  string  $type      DNS record type (A, AAAA, CNAME, etc.)
 * @param  string  $name      Record name (@ for root, subdomain for others)
 * @param  int     $ttl       Time to live in seconds
 *
 * @return void
 *
 * @throws DomainNotFound
 * @throws InvalidDnsRecord
 * @throws DomainProviderException
 */
public function deleteLicense(
    string $domain,
    string $ip,
    string $type = 'A',
    string $name = '@',
    int $ttl = 3600,
): void

API Documentation

Generate documentation from code:

# Using Laravel API docs generator
composer require --dev scribe-php/scribe
php artisan scribe:init
php artisan scribe:generate

Or write Markdown documentation:

# API Documentation

## Authentication

All endpoints require Bearer token authentication:

```http
Authorization: Bearer your-token-here
```

## Rate Limiting

- Limit: 100 requests per minute per token
- Headers: `X-RateLimit-*` included in every response

## Domain Endpoints

## Get Licenses

```http
GET /api/licenses
Authorization: Bearer {token}
```

Response:
```json
{
  "items": [
    {
      "key": "lic_abc123xyz",
      "name": "My License",
      "domain": "example.com",
      "status": "active",
      "expiresAt": "2025-12-31T23:59:59Z",
      "createdAt": "2024-01-01T00:00:00Z"
    }
  ]
}
```

Code Examples

Provide worked examples:

// examples/check-domain-availability.php
<?php

require 'vendor/autoload.php';

use GuzzleHttp\Client;

$client = new Client([
    'base_uri' => 'https://api.example.com',
    'http_errors' => false,
]);

$response = $client->post('/api/licenses', [
    'headers' => [
        'Authorization' => 'Bearer your-token',
    ],
    'json' => [
        'name' => 'My License',
        'domain' => 'example.com',
    ],
]);

$data = json_decode($response->getBody(), true);

if ($response->getStatusCode() === 201) {
    echo "License created: " . $data['data']['key'];
    echo "Status: " . $data['data']['status'];
} else {
    echo "Error: " . $data['error'];
}

Helpful Error Messages

Provide errors that help developers fix issues:

// ❌ Bad error message
"error": "Invalid input"

// ✅ Good error message
{
  "error": "Validation failed",
  "code": "VALIDATION_ERROR",
  "fields": {
    "email": ["Email must be valid"],
    "phone": ["Phone must be in E.164 format (+country-code-number)"]
  }
}

Backwards Compatibility

Maintain compatibility when evolving your API:

// Version your API
Route::prefix('/api/v1')->group(function () {
    // V1 endpoints
    Route::get('/domains', [LicenseControllerV1::class, 'index']);
});

Route::prefix('/api/v2')->group(function () {
    // V2 endpoints (better response format)
    Route::get('/domains', [LicenseControllerV2::class, 'index']);
});

// Deprecation headers
class DeprecationMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        if ($request->is('api/v1/*')) {
            $response->header('Deprecation', 'true');
            $response->header('Sunset', now()->addYear()->toRfc2822String());
            $response->header('Link', '<https://docs.example.com/v2>; rel="latest"');
        }

        return $response;
    }
}