baukasten/webservice

There is no license information available for the latest version (0.1.13) of this package.

The simple way to build REST-APIs

Installs: 2 090

Dependents: 2

Suggesters: 0

Security: 0

Stars: 0

Forks: 0

pkg:composer/baukasten/webservice

0.1.13 2026-01-09 11:55 UTC

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'] when id=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.