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

Maintainers

👁 saschahuber

Package info

gitlab.com/php-baukasten/webservice

Issues

pkg:composer/baukasten/webservice

Statistics

Installs: 2 491

Dependents: 2

Suggesters: 0

Stars: 0

0.1.13 2026-01-09 11:55 UTC

Requires

Requires (Dev)

Suggests

None

Provides

None

Conflicts

None

Replaces

None

Unknown License 4ef5b42d5eacd15fc9633cf9bc6425cfcb440e3e

  • Sascha Huber <kontakt.woop@sascha-huber.com>

This package is auto-updated.

Last update: 2026-06-10 10:30:48 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.