API Development
APIs (Application Programming Interfaces) allow different applications to communicate with each other. REST APIs use HTTP methods and JSON to provide a standardized way for frontends, mobile apps, and other services to interact with your server. PHP is an excellent choice for building APIs, whether you use plain PHP, a micro-framework, or a full framework like Laravel. In this lesson you will learn the patterns and practices behind well-designed REST APIs.
What You'll Learn
- REST API design principles and conventions
- HTTP methods and status codes
- Building JSON responses
- Request routing for APIs
- Input validation and error responses
- Building a complete CRUD API
- API versioning and best practices
REST Principles
REST (Representational State Transfer) uses standard HTTP methods to perform operations on resources. Each resource has a URL, and the HTTP method determines the action:
<?php
// REST API conventions:
// GET /api/posts - List all posts
// GET /api/posts/1 - Get post #1
// POST /api/posts - Create a new post
// PUT /api/posts/1 - Update post #1 (full replace)
// PATCH /api/posts/1 - Partial update post #1
// DELETE /api/posts/1 - Delete post #1
// HTTP Status Codes for APIs
$statusCodes = [
200 => 'OK - Successful request',
201 => 'Created - Resource created successfully',
204 => 'No Content - Successful deletion',
400 => 'Bad Request - Invalid input',
401 => 'Unauthorized - Authentication required',
403 => 'Forbidden - Not allowed',
404 => 'Not Found - Resource does not exist',
422 => 'Unprocessable Entity - Validation failed',
429 => 'Too Many Requests - Rate limited',
500 => 'Internal Server Error',
];
echo "Common HTTP Status Codes for APIs:\n";
foreach ($statusCodes as $code => $description) {
echo " {$code}: {$description}\n";
}
echo "\nREST Resource Naming Conventions:\n";
$conventions = [
'/api/users' => 'Collection (plural noun)',
'/api/users/42' => 'Single resource by ID',
'/api/users/42/posts' => 'Nested resource',
'/api/posts?status=published' => 'Filtering with query params',
'/api/posts?sort=created_at&order=desc' => 'Sorting',
'/api/posts?page=2&per_page=10' => 'Pagination',
];
foreach ($conventions as $url => $description) {
echo " {$url} - {$description}\n";
}
Good API design uses plural nouns for resources, query parameters for filtering and pagination, and appropriate HTTP status codes for every response.
JSON Responses
APIs communicate with JSON. PHP makes it easy to encode data and set the proper headers:
<?php
class ApiResponse {
public static function success(mixed $data, int $status = 200): array {
return [
'status' => $status,
'headers' => ['Content-Type' => 'application/json'],
'body' => json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
];
}
public static function created(mixed $data, string $location = ''): array {
$headers = ['Content-Type' => 'application/json'];
if ($location) {
$headers['Location'] = $location;
}
return [
'status' => 201,
'headers' => $headers,
'body' => json_encode($data, JSON_PRETTY_PRINT),
];
}
public static function error(string $message, int $status = 400, array $errors = []): array {
$body = ['error' => ['message' => $message, 'status' => $status]];
if (!empty($errors)) {
$body['error']['details'] = $errors;
}
return [
'status' => $status,
'headers' => ['Content-Type' => 'application/json'],
'body' => json_encode($body, JSON_PRETTY_PRINT),
];
}
public static function paginated(array $items, int $total, int $page, int $perPage): array {
$lastPage = (int) ceil($total / $perPage);
return self::success([
'data' => $items,
'meta' => [
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'last_page' => $lastPage,
'has_more' => $page < $lastPage,
],
]);
}
}
// Success response
$response = ApiResponse::success([
'id' => 1,
'name' => 'Alice',
'email' => 'alice@example.com',
]);
echo "=== Success Response (HTTP {$response['status']}) ===\n";
echo $response['body'] . "\n\n";
// Created response
$response = ApiResponse::created(
['id' => 42, 'title' => 'New Post', 'status' => 'draft'],
'/api/posts/42'
);
echo "=== Created Response (HTTP {$response['status']}) ===\n";
echo "Location: {$response['headers']['Location']}\n";
echo $response['body'] . "\n\n";
// Error response
$response = ApiResponse::error('Validation failed', 422, [
'email' => 'Invalid email format',
'name' => 'Name is required',
]);
echo "=== Error Response (HTTP {$response['status']}) ===\n";
echo $response['body'] . "\n\n";
// Paginated response
$posts = [
['id' => 1, 'title' => 'First Post'],
['id' => 2, 'title' => 'Second Post'],
];
$response = ApiResponse::paginated($posts, total: 25, page: 1, perPage: 10);
echo "=== Paginated Response ===\n";
echo $response['body'] . "\n";
Consistent response structure is critical for API consumers. Always include proper status codes, use a standard envelope format (like data and meta fields), and return structured error messages with enough detail for clients to fix issues.
Input Validation
Never trust client input. Validate every field before processing:
<?php
class Validator {
private array $errors = [];
public function validate(array $data, array $rules): bool {
$this->errors = [];
foreach ($rules as $field => $fieldRules) {
$value = $data[$field] ?? null;
foreach ($fieldRules as $rule) {
$error = $this->checkRule($field, $value, $rule);
if ($error !== null) {
$this->errors[$field][] = $error;
}
}
}
return empty($this->errors);
}
private function checkRule(string $field, mixed $value, string $rule): ?string {
if ($rule === 'required' && (is_null($value) || $value === '')) {
return "{$field} is required";
}
if ($rule === 'email' && $value && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
return "{$field} must be a valid email address";
}
if (str_starts_with($rule, 'min:')) {
$min = (int) substr($rule, 4);
if (is_string($value) && strlen($value) < $min) {
return "{$field} must be at least {$min} characters";
}
}
if (str_starts_with($rule, 'max:')) {
$max = (int) substr($rule, 4);
if (is_string($value) && strlen($value) > $max) {
return "{$field} must not exceed {$max} characters";
}
}
if (str_starts_with($rule, 'in:')) {
$allowed = explode(',', substr($rule, 3));
if ($value && !in_array($value, $allowed)) {
return "{$field} must be one of: " . implode(', ', $allowed);
}
}
return null;
}
public function getErrors(): array {
return $this->errors;
}
}
// Define validation rules
$rules = [
'title' => ['required', 'min:3', 'max:100'],
'body' => ['required', 'min:10'],
'status' => ['required', 'in:draft,published,archived'],
'author_email' => ['required', 'email'],
];
$validator = new Validator();
// Valid input
$validData = [
'title' => 'My Great Post',
'body' => 'This is the content of my blog post with plenty of detail.',
'status' => 'published',
'author_email' => 'alice@example.com',
];
echo "=== Valid Input ===\n";
if ($validator->validate($validData, $rules)) {
echo "Validation passed!\n";
} else {
echo "Errors: " . json_encode($validator->getErrors(), JSON_PRETTY_PRINT) . "\n";
}
// Invalid input
$invalidData = [
'title' => 'Hi',
'body' => 'Short',
'status' => 'unknown',
'author_email' => 'not-an-email',
];
echo "\n=== Invalid Input ===\n";
if ($validator->validate($invalidData, $rules)) {
echo "Validation passed!\n";
} else {
foreach ($validator->getErrors() as $field => $fieldErrors) {
foreach ($fieldErrors as $error) {
echo " - {$error}\n";
}
}
}
Laravel provides a much more powerful validation system with dozens of built-in rules. The concept is the same: define rules for each field, validate the input, and return clear error messages for any violations.
Building a CRUD API
Here is a complete API controller demonstrating all CRUD operations with validation and error handling:
<?php
class PostApiController {
private array $posts = [];
private int $nextId = 1;
public function __construct() {
// Seed some initial data
$this->posts[1] = ['id' => 1, 'title' => 'Getting Started with PHP APIs', 'body' => 'Learn how to build REST APIs...', 'status' => 'published', 'created_at' => '2024-01-15'];
$this->posts[2] = ['id' => 2, 'title' => 'Database Best Practices', 'body' => 'Tips for working with databases...', 'status' => 'published', 'created_at' => '2024-01-20'];
$this->posts[3] = ['id' => 3, 'title' => 'Draft Ideas', 'body' => 'Some ideas for future posts...', 'status' => 'draft', 'created_at' => '2024-02-01'];
$this->nextId = 4;
}
// GET /api/posts
public function index(array $query = []): array {
$posts = array_values($this->posts);
// Filter by status
if (isset($query['status'])) {
$posts = array_filter($posts, fn($p) => $p['status'] === $query['status']);
$posts = array_values($posts);
}
return ['status' => 200, 'data' => $posts, 'total' => count($posts)];
}
// GET /api/posts/{id}
public function show(int $id): array {
if (!isset($this->posts[$id])) {
return ['status' => 404, 'error' => "Post #{$id} not found"];
}
return ['status' => 200, 'data' => $this->posts[$id]];
}
// POST /api/posts
public function store(array $data): array {
$errors = $this->validate($data);
if (!empty($errors)) {
return ['status' => 422, 'error' => 'Validation failed', 'details' => $errors];
}
$id = $this->nextId++;
$this->posts[$id] = [
'id' => $id,
'title' => $data['title'],
'body' => $data['body'],
'status' => $data['status'] ?? 'draft',
'created_at' => date('Y-m-d'),
];
return ['status' => 201, 'data' => $this->posts[$id]];
}
// PUT /api/posts/{id}
public function update(int $id, array $data): array {
if (!isset($this->posts[$id])) {
return ['status' => 404, 'error' => "Post #{$id} not found"];
}
$errors = $this->validate($data);
if (!empty($errors)) {
return ['status' => 422, 'error' => 'Validation failed', 'details' => $errors];
}
$this->posts[$id] = array_merge($this->posts[$id], [
'title' => $data['title'],
'body' => $data['body'],
'status' => $data['status'] ?? $this->posts[$id]['status'],
]);
return ['status' => 200, 'data' => $this->posts[$id]];
}
// DELETE /api/posts/{id}
public function destroy(int $id): array {
if (!isset($this->posts[$id])) {
return ['status' => 404, 'error' => "Post #{$id} not found"];
}
unset($this->posts[$id]);
return ['status' => 204, 'data' => null];
}
private function validate(array $data): array {
$errors = [];
if (empty($data['title'])) $errors['title'] = 'Title is required';
if (empty($data['body'])) $errors['body'] = 'Body is required';
if (isset($data['status']) && !in_array($data['status'], ['draft', 'published'])) {
$errors['status'] = 'Status must be draft or published';
}
return $errors;
}
}
$api = new PostApiController();
// List all posts
echo "=== GET /api/posts ===\n";
$result = $api->index();
echo json_encode($result, JSON_PRETTY_PRINT) . "\n\n";
// Filter published posts
echo "=== GET /api/posts?status=published ===\n";
$result = $api->index(['status' => 'published']);
echo "Found {$result['total']} published posts\n\n";
// Get single post
echo "=== GET /api/posts/1 ===\n";
$result = $api->show(1);
echo "Title: {$result['data']['title']}\n\n";
// Create post
echo "=== POST /api/posts ===\n";
$result = $api->store(['title' => 'New API Post', 'body' => 'Created via API', 'status' => 'published']);
echo "Created: [{$result['status']}] ID #{$result['data']['id']}: {$result['data']['title']}\n\n";
// Update post
echo "=== PUT /api/posts/2 ===\n";
$result = $api->update(2, ['title' => 'Updated Title', 'body' => 'Updated content']);
echo "Updated: {$result['data']['title']}\n\n";
// Delete post
echo "=== DELETE /api/posts/3 ===\n";
$result = $api->destroy(3);
echo "Deleted: HTTP {$result['status']}\n\n";
// 404 example
echo "=== GET /api/posts/999 ===\n";
$result = $api->show(999);
echo "Error: {$result['error']}\n";
This pattern -- validate input, perform the operation, return a structured response -- is the core of API development. Each method maps to an HTTP verb and returns the appropriate status code.
Key Takeaways
- REST APIs use HTTP methods (GET, POST, PUT, PATCH, DELETE) as verbs and URLs as nouns
- Always return JSON with the
Content-Type: application/jsonheader - Use appropriate HTTP status codes: 200 for success, 201 for created, 404 for not found, 422 for validation errors
- Validate all input before processing and return clear error messages
- Use consistent response envelopes with
data,meta, anderrorfields - Paginate list endpoints to prevent large responses
- Version your API (e.g.,
/api/v1/posts) to allow breaking changes without affecting existing clients - Never expose internal implementation details in error responses
Next Steps
You can now build complete CRUD APIs with proper HTTP methods, status codes, input validation, and structured JSON responses. Your PHP applications can serve data to any client.
An API that anyone can access is a security risk. The moment your application handles user data -- accounts, passwords, personal information -- you need to protect it. Authentication and security covers password hashing with bcrypt, CSRF protection, XSS prevention, SQL injection defense, and session-based login flows. These are not optional extras; they are requirements for any application that goes to production, and understanding them deeply will make you a more trusted and employable developer.
Continue to Authentication and Security -->
Pro Tip: Use API documentation tools like OpenAPI (Swagger) to define your API endpoints, request bodies, and responses. This creates a contract between your API and its consumers, and tools can auto-generate client libraries and interactive documentation from the specification. In Laravel, packages like
scribeorl5-swaggergenerate API docs automatically from your route and controller definitions.