baukasten / webservice
The simple way to build REST-APIs
Installs: 2 090
Dependents: 2
Suggesters: 0
Security: 0
Stars: 0
Forks: 0
pkg:composer/baukasten/webservice
Requires
- php: ^8.1
- baukasten/cache: ^0.0.4
Requires (Dev)
- guzzle/http: v3.9.2
- mockery/mockery: 1.6.11
- phpunit/phpunit: ^10
- symfony/process: ^6.0|^7.0
README
A lightweight PHP library for building REST APIs using an attribute-based routing system. Define endpoints, middleware, and handle HTTP requests with minimal boilerplate.
Features
- 🎯 Attribute-based routing - Define routes using PHP 8 attributes
- 🔧 Middleware system - CORS, file validation, caching, and custom middleware
- 🚀 Response caching - Built-in HTTP response caching with tag-based invalidation
- 📦 Multiple response types - JSON, HTML, redirects, and custom responses
- 🛣️ Dynamic routing - URL parameters and wildcard matching
- ✅ Type-safe - Leverages PHP 8+ type system
Requirements
- PHP 8.1 or higher
- Composer
Installation
composer require baukasten/webservice
Quick Start
1. Create a Controller
<?php
use Baukasten\Webservice\Controller\Controller;
use Baukasten\Webservice\Attribute\Endpoint\Get;
use Baukasten\Webservice\Attribute\Endpoint\Post;
use Baukasten\Webservice\Request;
use Baukasten\Webservice\Response\JsonResponse;
use Baukasten\Webservice\Response\Response;
class ApiController extends Controller
{
public function __construct()
{
parent::__construct('api'); // All routes prefixed with 'api/'
}
#[Get("hello")]
public function hello(Request $request): Response
{
return new JsonResponse(['message' => 'Hello World!']);
}
#[Get("users/{id}")]
public function getUser(Request $request): Response
{
$id = $request->getUrlParams()['id'];
return new JsonResponse(['user_id' => $id]);
}
#[Post("users")]
public function createUser(Request $request): Response
{
$data = json_decode($request->getBody(), true);
return new JsonResponse(['created' => $data], 201);
}
}
2. Bootstrap Your Application
<?php
// index.php
require_once __DIR__ . '/vendor/autoload.php';
use Baukasten\Webservice\Router;
Router::handleRequest([
ApiController::class,
// Add more controllers here
]);
3. Test Your API
curl http://localhost/api/hello
# {"message":"Hello World!"}
curl http://localhost/api/users/123
# {"user_id":"123"}
Core Concepts
Controllers
Controllers group related endpoints and define a route prefix.
class UserController extends Controller
{
public function __construct()
{
parent::__construct('users'); // Prefix: /users
}
// Optional: Pre-execution hook for auth, validation, etc.
public function prepare(Request $request): ?Response
{
// Check authentication
if (!$this->isAuthenticated($request)) {
return new ErrorResponse('Unauthorized', 401);
}
return null; // Continue to endpoint
}
#[Get("")]
public function list(Request $request): Response
{
return new JsonResponse($this->getAllUsers());
}
#[Get("{id}")]
public function get(Request $request): Response
{
$id = $request->getUrlParams()['id'];
return new JsonResponse($this->findUser($id));
}
}
Endpoint Attributes
Define HTTP methods and paths with attributes:
#[Get("path")] // HTTP GET
#[Post("path")] // HTTP POST
#[Put("path")] // HTTP PUT
#[Delete("path")] // HTTP DELETE
Route Patterns
URL Parameters:
#[Get("users/{id}")] // Matches: /users/123
#[Get("posts/{postId}/comments/{id}")] // Matches: /posts/5/comments/10
Wildcards:
#[Get("blog/*")] // Matches: /blog/2024/post, /blog/article
#[Get("api/*/details")] // Matches: /api/foo/bar/details
Accessing Parameters:
#[Get("users/{id}")]
public function getUser(Request $request): Response
{
$params = $request->getUrlParams();
$id = $params['id'];
// ...
}
Request Object
The Request object provides access to all request data:
public function handle(Request $request): Response
{
// HTTP method
$method = $request->getMethod(); // GET, POST, PUT, DELETE
// URL path
$path = $request->getPath(); // /api/users/123
$segments = $request->getPathSegments(); // ['api', 'users', '123']
// URL parameters (from route pattern)
$urlParams = $request->getUrlParams(); // ['id' => '123']
// Query parameters (?page=1&limit=10)
$queryParams = $request->getQueryParams();
$page = $queryParams['page'] ?? 1;
// POST data
$postParams = $request->getPostParams();
// All parameters (query + POST)
$allParams = $request->getAllParams();
// Raw request body
$body = $request->getBody();
$data = json_decode($body, true);
// HTTP headers
$headers = $request->getHeaders();
$contentType = $headers['Content-Type'] ?? '';
// Uploaded files
$files = $request->getFiles();
$file = $request->getFile('avatar');
return new JsonResponse(['status' => 'ok']);
}
Response Types
JsonResponse:
return new JsonResponse(['key' => 'value'], 200);
HtmlResponse:
return new HtmlResponse('<h1>Hello</h1>', 200);
ErrorResponse:
return new ErrorResponse('Not Found', 404);
// Returns: {"error":"Not Found"}
Redirect:
return new Redirect('/login', 302);
Custom Response:
return new Response('Plain text', 200, [
'Content-Type' => 'text/plain',
'X-Custom-Header' => 'value'
]);
Middleware
Middleware are attributes that execute before your controller methods.
Built-in Middleware
CORS
Enable Cross-Origin Resource Sharing:
use Baukasten\Webservice\Attribute\Middleware\Cors;
#[Get("api/data")]
#[Cors(allowedOrigins: ['https://example.com'], allowedMethods: [HttpMethod::GET, HttpMethod::POST])]
public function getData(Request $request): Response
{
return new JsonResponse(['data' => 'value']);
}
// Allow all origins
#[Cors(allowedOrigins: ['*'])]
MaxFileSize
Validate uploaded file sizes:
use Baukasten\Webservice\Attribute\Middleware\MaxFileSize;
#[Post("upload")]
#[MaxFileSize(fileKey: 'avatar', maxFileSize: 5242880)] // 5MB
public function upload(Request $request): Response
{
$file = $request->getFile('avatar');
// Process file
return new JsonResponse(['uploaded' => true]);
}
Cacheable
Cache GET request responses:
use Baukasten\Webservice\Attribute\Middleware\Cacheable;
#[Get("users")]
#[Cacheable(ttl: 3600, tags: ['users'])]
public function listUsers(Request $request): Response
{
return new JsonResponse($this->getAllUsers());
}
See the Response Caching section for details.
Custom Middleware
Create your own middleware by extending the Middleware class:
<?php
use Baukasten\Webservice\Attribute\Middleware\Middleware;
use Baukasten\Webservice\Request;
use Baukasten\Webservice\Response\Response;
use Baukasten\Webservice\Response\ErrorResponse;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class RequireAuth extends Middleware
{
public function __construct(
private string $role = 'user'
) {}
public function __invoke(Request $request): ?Response
{
$token = $request->getHeaders()['Authorization'] ?? '';
if (!$this->validateToken($token)) {
return new ErrorResponse('Unauthorized', 401);
}
if (!$this->hasRole($token, $this->role)) {
return new ErrorResponse('Forbidden', 403);
}
return null; // Continue to controller
}
private function validateToken(string $token): bool
{
// Your validation logic
return !empty($token);
}
private function hasRole(string $token, string $role): bool
{
// Your role checking logic
return true;
}
}
Usage:
#[Get("admin/users")]
#[RequireAuth(role: 'admin')]
public function adminUsers(Request $request): Response
{
return new JsonResponse($this->getAllUsers());
}
Response Caching
The library includes powerful response caching with tag-based invalidation.
Setup
Initialize caching in your bootstrap file:
<?php
// index.php
use Baukasten\Webservice\ResponseCache;
use Baukasten\Cache\Buckets\FileBucket;
// Option 1: Auto-initialize with defaults (FileBucket in temp dir)
ResponseCache::init();
// Option 2: Custom file cache directory
ResponseCache::init([
'cache_dir' => '/var/cache/my-app/responses'
]);
// Option 3: Redis cache
use Baukasten\Cache\Buckets\RedisBucket;
$redis = new RedisBucket(
host: '127.0.0.1',
port: 6379,
password: null,
database: 0,
prefix: 'api:',
defaultTtl: 3600
);
ResponseCache::init([
'bucket' => $redis,
'bucket_name' => 'api_cache'
]);
// Then handle requests
Router::handleRequest([MyController::class]);
Basic Caching
use Baukasten\Webservice\Attribute\Middleware\Cacheable;
#[Get("products")]
#[Cacheable(ttl: 3600)] // Cache for 1 hour
public function listProducts(Request $request): Response
{
return new JsonResponse($this->getAllProducts());
}
Cacheable Parameters
#[Cacheable(
ttl: 3600, // Time-to-live in seconds
tags: ['products'], // Tags for invalidation
ignoreQueryParams: false, // Include query params in cache key
keyGenerator: null, // Custom key generator callable
bucket: null, // Custom cache bucket name
addHttpHeaders: true, // Add Cache-Control, ETag headers
varyHeaders: [], // Headers to include in cache key
cacheOnlySuccessful: true // Only cache 2xx responses
)]
Tag-Based Invalidation
Use tags to group and invalidate related cache entries:
class ProductController extends Controller
{
#[Get("products")]
#[Cacheable(ttl: 3600, tags: ['products'])]
public function listProducts(Request $request): Response
{
return new JsonResponse($this->getAllProducts());
}
#[Get("products/{id}")]
#[Cacheable(ttl: 1800, tags: ['products', 'product:{id}'])]
public function getProduct(Request $request): Response
{
$id = $request->getUrlParams()['id'];
return new JsonResponse($this->findProduct($id));
}
#[Post("products")]
public function createProduct(Request $request): Response
{
$data = json_decode($request->getBody(), true);
$product = $this->create($data);
// Invalidate all product caches
ResponseCache::invalidateByTag('products');
return new JsonResponse($product, 201);
}
#[Put("products/{id}")]
public function updateProduct(Request $request): Response
{
$id = $request->getUrlParams()['id'];
$data = json_decode($request->getBody(), true);
$product = $this->update($id, $data);
// Invalidate specific product and list
ResponseCache::invalidateByTag('product:' . $id);
ResponseCache::invalidateByTag('products');
return new JsonResponse($product);
}
}
Tag placeholders are automatically resolved:
tags: ['product:{id}']→tags: ['product:123']whenid=123
Query Parameters
Include query params in cache key (default):
#[Cacheable(ttl: 3600)]
public function search(Request $request): Response
{
// /api/search?q=foo and /api/search?q=bar cache separately
}
Ignore query params (useful for tracking params):
#[Cacheable(ttl: 3600, ignoreQueryParams: true)]
public function getStats(Request $request): Response
{
// /api/stats?utm_source=email and /api/stats?utm_source=twitter
// share the same cache
}
Vary by Headers
Cache different versions based on request headers:
#[Cacheable(
ttl: 3600,
varyHeaders: ['Accept-Language']
)]
public function getContent(Request $request): Response
{
$lang = $request->getHeaders()['Accept-Language'] ?? 'en';
// Returns different cached responses for different languages
}
Custom Cache Keys
Define your own cache key logic:
class CacheKeys
{
public static function userSpecific(Request $request): string
{
$userId = $request->getHeaders()['X-User-ID'] ?? 'anonymous';
$path = $request->getPath();
return "user:{$userId}:{$path}";
}
}
#[Get("profile")]
#[Cacheable(
ttl: 300,
keyGenerator: 'CacheKeys::userSpecific'
)]
public function getProfile(Request $request): Response
{
// Each user gets their own cached response
}
HTTP Cache Headers
When addHttpHeaders: true (default), the following headers are added:
Cache-Control: public, max-age=3600
ETag: "5d41402abc4b2a76b9719d911017c592"
X-Cache-Status: HIT|MISS
Vary: Accept-Language (if varyHeaders specified)
Disable HTTP headers:
#[Cacheable(ttl: 3600, addHttpHeaders: false)]
public function getData(Request $request): Response
{
// Cache internally but don't send cache headers to client
}
Cache Management
Invalidate by tag:
use Baukasten\Webservice\ResponseCache;
ResponseCache::invalidateByTag('users');
ResponseCache::invalidateByTag('user:123');
Clear all cached responses:
ResponseCache::clear();
Clear specific bucket:
ResponseCache::clear('custom_bucket');
Disable caching globally:
// Caching is disabled by default until init() is called
// ResponseCache::init() automatically enables caching
// Disable caching after initialization (useful for development)
ResponseCache::disable();
// Re-enable when needed
ResponseCache::enable();
// Check if enabled
if (ResponseCache::isEnabled()) {
// ...
}
Example: Disable caching in development:
// index.php
use Baukasten\Webservice\ResponseCache;
// Initialize caching (this enables caching automatically)
ResponseCache::init();
// Disable in dev environment
if ($_ENV['APP_ENV'] === 'dev') {
ResponseCache::disable();
}
Note: Caching is disabled by default until ResponseCache::init() is called. Calling init() automatically enables caching. You can then disable it with disable() if needed.
Cache Backends
FileBucket (default):
- Stores cache on disk
- Survives server restarts
- Good for single-server setups
MemoryBucket:
- In-memory storage (lost when process ends)
- Fastest option
- Good for development/testing
RedisBucket:
- Shared across multiple servers
- Persistent and fast
- Best for production multi-server environments
Complete Example
Here's a full REST API example with caching, CORS, and error handling:
<?php
// src/Controllers/ApiController.php
use Baukasten\Webservice\Controller\Controller;
use Baukasten\Webservice\Attribute\Endpoint\{Get, Post, Put, Delete};
use Baukasten\Webservice\Attribute\Middleware\{Cacheable, Cors};
use Baukasten\Webservice\Request;
use Baukasten\Webservice\Response\{JsonResponse, ErrorResponse, Response};
use Baukasten\Webservice\ResponseCache;
use Baukasten\Webservice\HttpMethod;
class ApiController extends Controller
{
public function __construct()
{
parent::__construct('api/v1');
}
// Optional: Authentication check for all endpoints
public function prepare(Request $request): ?Response
{
// Allow public endpoints
$publicPaths = ['/api/v1/health', '/api/v1/products'];
if (in_array($request->getPath(), $publicPaths)) {
return null;
}
// Check API key
$apiKey = $request->getHeaders()['X-API-Key'] ?? '';
if (!$this->isValidApiKey($apiKey)) {
return new ErrorResponse('Invalid API key', 401);
}
return null;
}
#[Get("health")]
#[Cors(allowedOrigins: ['*'])]
public function health(Request $request): Response
{
return new JsonResponse([
'status' => 'ok',
'timestamp' => time()
]);
}
#[Get("products")]
#[Cors(allowedOrigins: ['*'])]
#[Cacheable(ttl: 3600, tags: ['products'])]
public function listProducts(Request $request): Response
{
$page = (int)($request->getQueryParams()['page'] ?? 1);
$limit = (int)($request->getQueryParams()['limit'] ?? 20);
$products = $this->productRepository->paginate($page, $limit);
return new JsonResponse([
'data' => $products,
'page' => $page,
'total' => count($products)
]);
}
#[Get("products/{id}")]
#[Cors(allowedOrigins: ['*'])]
#[Cacheable(ttl: 1800, tags: ['products', 'product:{id}'])]
public function getProduct(Request $request): Response
{
$id = $request->getUrlParams()['id'];
$product = $this->productRepository->find($id);
if (!$product) {
return new ErrorResponse('Product not found', 404);
}
return new JsonResponse($product);
}
#[Post("products")]
#[Cors(
allowedOrigins: ['https://admin.example.com'],
allowedMethods: [HttpMethod::POST]
)]
public function createProduct(Request $request): Response
{
$data = json_decode($request->getBody(), true);
if (!$this->validateProduct($data)) {
return new ErrorResponse('Invalid product data', 400);
}
$product = $this->productRepository->create($data);
// Invalidate product list cache
ResponseCache::invalidateByTag('products');
return new JsonResponse($product, 201);
}
#[Put("products/{id}")]
#[Cors(
allowedOrigins: ['https://admin.example.com'],
allowedMethods: [HttpMethod::PUT]
)]
public function updateProduct(Request $request): Response
{
$id = $request->getUrlParams()['id'];
$data = json_decode($request->getBody(), true);
$product = $this->productRepository->update($id, $data);
if (!$product) {
return new ErrorResponse('Product not found', 404);
}
// Invalidate specific product and list
ResponseCache::invalidateByTag('product:' . $id);
ResponseCache::invalidateByTag('products');
return new JsonResponse($product);
}
#[Delete("products/{id}")]
#[Cors(
allowedOrigins: ['https://admin.example.com'],
allowedMethods: [HttpMethod::DELETE]
)]
public function deleteProduct(Request $request): Response
{
$id = $request->getUrlParams()['id'];
$deleted = $this->productRepository->delete($id);
if (!$deleted) {
return new ErrorResponse('Product not found', 404);
}
// Invalidate caches
ResponseCache::invalidateByTag('product:' . $id);
ResponseCache::invalidateByTag('products');
return new JsonResponse(['deleted' => true], 204);
}
private function isValidApiKey(string $apiKey): bool
{
// Your API key validation logic
return $apiKey === 'your-secret-key';
}
private function validateProduct(array $data): bool
{
return isset($data['name']) && isset($data['price']);
}
}
Bootstrap:
<?php
// public/index.php
require_once __DIR__ . '/../vendor/autoload.php';
use Baukasten\Webservice\Router;
use Baukasten\Webservice\ResponseCache;
// Initialize response caching
ResponseCache::init([
'cache_dir' => __DIR__ . '/../cache/responses'
]);
// Handle requests
Router::handleRequest([
ApiController::class,
// Add more controllers
]);
Development
Running Tests
With Docker (recommended):
./run-phpunit.sh # Run all tests
./run-phpunit.sh --testsuite Unit # Run unit tests
./run-phpunit.sh --testsuite EndToEnd # Run integration tests
./run-phpunit.sh --filter RouteTest # Run specific test
./run-phpunit.sh --coverage-text # Generate coverage report
Without Docker:
COMPOSER=composer-dev.json composer update --ignore-platform-reqs
vendor/bin/phpunit
Installing Dependencies
# Development environment
COMPOSER=composer-dev.json composer update --ignore-platform-reqs
# Production
composer install --no-dev
License
MIT License
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.