bermudaphp / psr15factory
Powerful and flexible factory for creating PSR-15 compatible middleware with automatic dependency injection and request data mapping
Requires
- php: ^8.4
- bermudaphp/config: ^2.0
- bermudaphp/container-aware: ^1.0.1
- bermudaphp/di-resolver: ^1.0
- bermudaphp/pipeline: ^2.1
- bermudaphp/reflection: ^2.0
- psr/container: ^2.0.2
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
- phpunit/phpunit: ^11.5
This package is auto-updated.
Last update: 2025-06-29 11:02:41 UTC
README
A powerful and flexible factory for creating PSR-15 compatible middleware in PHP. Supports various types of middleware definitions, automatic dependency injection, request data mapping, and multiple middleware patterns.
Requirements
- PHP 8.4+
- PSR-7 HTTP Message Interface
- PSR-11 Container Interface
- PSR-15 HTTP Server Request Handlers
Installation
composer require bermudaphp/psr15factory
Quick Start
Basic Setup
use Bermuda\MiddlewareFactory\MiddlewareFactory; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; // Create factory $factory = MiddlewareFactory::createFromContainer($container); // Create middleware from various definitions $middleware1 = $factory->makeMiddleware('App\\Middleware\\AuthMiddleware'); $middleware2 = $factory->makeMiddleware(function(ServerRequestInterface $request, callable $next): ResponseInterface { // Single-pass middleware return $next($request); }); $middleware3 = $factory->makeMiddleware([$middlewareArray]);
Controller Example
use Bermuda\MiddlewareFactory\Attribute\MapQueryParameter; use Bermuda\MiddlewareFactory\Attribute\MapRequestPayload; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; class UserController { public function getUsers( #[MapQueryParameter] int $page = 1, #[MapQueryParameter] int $limit = 10, #[MapQueryParameter('q')] string $search = '' ): ResponseInterface { // Automatically extracts ?page=2&limit=20&q=john // $page = 2, $limit = 20, $search = "john" return new JsonResponse(['users' => $this->userService->find($search, $page, $limit)]); } public function createUser( #[MapRequestPayload(['full_name' => 'name'])] CreateUserRequest $request ): ResponseInterface { // Automatically maps JSON payload to DTO with field renaming return new JsonResponse(['user' => $this->userService->create($request)]); } }
Middleware Types
1. Class Name Strategy
Resolves middleware by class name from the container:
// Register in container $container->set(AuthMiddleware::class, new AuthMiddleware()); // Usage $middleware = $factory->makeMiddleware(AuthMiddleware::class);
2. Callable Strategy
Automatically detects and adapts various callable patterns. Supports multiple callable formats through built-in CallableResolver:
Supported callable formats:
1. Closures
$middleware = $factory->makeMiddleware(function(ServerRequestInterface $request, callable $next): ResponseInterface { return $next($request); });
2. Standard PHP callables
$middleware = $factory->makeMiddleware([$object, 'methodName']); $middleware = $factory->makeMiddleware('globalFunction');
3. String representations:
- Static class methods:
"Class::method"
$middleware = $factory->makeMiddleware('App\\Middleware\\AuthMiddleware::handle');
- Container service methods:
"serviceId::method"
$middleware = $factory->makeMiddleware('auth.service::authenticate');
- Global functions:
"functionName"
$middleware = $factory->makeMiddleware('myGlobalMiddlewareFunction');
- Callable services from container:
"serviceId"
// Service that is itself callable $middleware = $factory->makeMiddleware('custom.middleware.service');
4. Arrays:
- Object and method:
[object, method]
$authService = new AuthService(); $middleware = $factory->makeMiddleware([$authService, 'authenticate']);
- Service ID and method:
[serviceId, method]
$middleware = $factory->makeMiddleware(['user.service', 'validateToken']);
Middleware patterns (automatic detection):
Single-pass middleware
$middleware = $factory->makeMiddleware(function(ServerRequestInterface $request, callable $next): ResponseInterface { // Pre-processing $request = $request->withAttribute('timestamp', time()); // Call next middleware $response = $next($request); // Post-processing return $response->withHeader('X-Processing-Time', microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']); }); // Or through service $middleware = $factory->makeMiddleware('logging.middleware::process');
Double-pass middleware
$middleware = $factory->makeMiddleware(function( ServerRequestInterface $request, ResponseInterface $response, callable $next ): ResponseInterface { // Work with base response object if ($request->getHeaderLine('Accept') === 'application/json') { return $next($request); } return $response->withStatus(406); }); // Or through class $middleware = $factory->makeMiddleware('App\\Middleware\\LegacyMiddleware::handle');
Standard callable with DI
$middleware = $factory->makeMiddleware(function( ServerRequestInterface $request, #[Inject('user.service')] UserService $userService, #[MapQueryParameter] string $token ): ResponseInterface { $user = $userService->findByToken($token); if (!$user) { return new JsonResponse(['error' => 'Invalid token'], 401); } return new JsonResponse(['user' => $user]); }); // Or through service with DI $middleware = $factory->makeMiddleware('api.middleware::handleAuth');
Factory Callable
For dynamic middleware creation using the container:
// Factory callable - created only on first use $middleware = $factory->makeMiddleware(static function(ContainerInterface $c) use ($uri, $permanent): RedirectMiddleware { return new RedirectMiddleware($uri, $c->get(ResponseFactoryInterface::class), $permanent); }); $middleware instanceof MiddlewareInterface; // true $middleware instanceof RedirectMiddleware; // true // Complex factory callable with configuration $authMiddleware = $factory->makeMiddleware(static function(ContainerInterface $c): AuthMiddleware { $config = $c->get('config'); $jwtSecret = $config['auth']['jwt_secret']; $tokenTtl = $config['auth']['token_ttl'] ?? 3600; return new AuthMiddleware( $c->get(JwtService::class), $c->get(UserRepository::class), $jwtSecret, $tokenTtl ); }); // Conditional factory callable $compressionMiddleware = $factory->makeMiddleware(static function(ContainerInterface $c): MiddlewareInterface { $config = $c->get('config'); if ($config['compression']['enabled'] ?? false) { return new CompressionMiddleware( $config['compression']['level'] ?? 6, $config['compression']['types'] ?? ['text/html', 'application/json'] ); } // Return empty middleware if compression is disabled return new class implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { return $handler->handle($request); } }; });
Examples with different formats:
// Container with various middleware services $container->set('auth.middleware', new AuthenticationMiddleware()); $container->set('auth.service', new AuthService()); $container->set('rate.limiter', new RateLimitMiddleware()); // Various ways to create middleware: // 1. Direct closure $middleware1 = $factory->makeMiddleware(function($request, $next) { return $next($request); }); // 2. Service-middleware from container $middleware2 = $factory->makeMiddleware('auth.middleware'); // 3. Service method from container $middleware3 = $factory->makeMiddleware('auth.service::validateRequest'); // 4. Static class method $middleware4 = $factory->makeMiddleware('App\\Utils\\SecurityUtils::checkOrigin'); // 5. Array with service and method $middleware5 = $factory->makeMiddleware(['rate.limiter', 'handle']); // 6. Global function $middleware6 = $factory->makeMiddleware('customSecurityHandler'); // 7. Direct object and method $corsHandler = new CorsHandler(); $middleware7 = $factory->makeMiddleware([$corsHandler, 'process']);
3. Pipeline Strategy and MiddlewareGroup
For combining multiple middleware into a single pipeline:
use Bermuda\MiddlewareFactory\MiddlewareGroup; $group = new MiddlewareGroup([ 'App\\Middleware\\AuthMiddleware', ['cors.service', 'handle'], function($request, $next) { return $next($request); } ]); $middleware = $factory->makeMiddleware($group); // Adding middleware $newGroup = $group->add('rate.limiter::check'); $newGroup = $group->addMany(['cache.middleware', 'response.formatter']); // Checks echo $group->count(); // Number of middleware foreach ($group as $definition) { echo get_debug_type($definition) . "\n"; }
Request Data Mapping
The factory supports automatic extraction and transformation of data from PSR-7 requests into method parameters using PHP 8+ attributes. This provides clean separation between HTTP data handling and business logic.
Query parameters
The #[MapQueryParameter]
attribute extracts individual parameters from the query string:
use Bermuda\MiddlewareFactory\Attribute\MapQueryParameter; public function search( #[MapQueryParameter] string $query, // ?query=something #[MapQueryParameter('p')] int $page = 1, // ?p=2 (extracts parameter 'p') #[MapQueryParameter] ?string $category = null // ?category=books (optional) ): ResponseInterface { // $query = "something", $page = 2, $category = "books" $results = $this->searchService->search($query, $page, $category); return new JsonResponse(['results' => $results]); } // Advanced usage with typing public function filter( #[MapQueryParameter] int $page = 1, // Automatic cast to int #[MapQueryParameter] bool $active = true, // String "true"/"false" → bool #[MapQueryParameter] array $tags = [], // Multiple values ?tags[]=php&tags[]=api #[MapQueryParameter('min_price')] ?float $minPrice = null // Extract 'min_price' as float ): ResponseInterface { // All parameters automatically cast to appropriate types $filters = compact('page', 'active', 'tags', 'minPrice'); $products = $this->productService->filter($filters); return new JsonResponse(['products' => $products]); }
Query string (complete parameter set)
The #[MapQueryString]
attribute extracts all query parameters with optional field renaming:
use Bermuda\MiddlewareFactory\Attribute\MapQueryString; public function advancedSearch( #[MapQueryString] array $filters ): ResponseInterface { // ?name=John&age=25&city=NYC&sort=name&dir=asc // $filters = ['name' => 'John', 'age' => '25', 'city' => 'NYC', 'sort' => 'name', 'dir' => 'asc'] return $this->processFilters($filters); } // With field renaming public function listProducts( #[MapQueryString(['sort' => 'sortBy', 'dir' => 'direction', 'q' => 'search'])] array $queryParams ): ResponseInterface { // ?sort=price&dir=asc&q=laptop&limit=10&offset=20 // $queryParams = [ // 'sortBy' => 'price', // field 'sort' renamed to 'sortBy' // 'direction' => 'asc', // field 'dir' renamed to 'direction' // 'search' => 'laptop', // field 'q' renamed to 'search' // 'limit' => '10', // kept as is // 'offset' => '20' // kept as is // ] $products = $this->productService->search($queryParams); return new JsonResponse(['products' => $products]); } // Usage with DTO public function searchWithDTO( #[MapQueryString(['q' => 'query', 'cat' => 'category'])] SearchCriteria $criteria ): ResponseInterface { // Query parameters automatically mapped to SearchCriteria object // 'q' → 'query', 'cat' → 'category' during object creation $results = $this->searchService->search($criteria); return new JsonResponse($results); }
Request payload (request body)
The #[MapRequestPayload]
attribute extracts data from request body (JSON, form data, XML):
use Bermuda\MiddlewareFactory\Attribute\MapRequestPayload; public function createUser( #[MapRequestPayload] array $userData ): ResponseInterface { // POST /users // Content-Type: application/json // {"name": "John", "email": "john@example.com", "age": 25} // // $userData = ['name' => 'John', 'email' => 'john@example.com', 'age' => 25] $user = $this->userService->create($userData); return new JsonResponse(['user' => $user], 201); } // With field renaming API → internal names public function updateProfile( #[MapRequestPayload(['full_name' => 'name', 'phone_number' => 'phone'])] array $profileData ): ResponseInterface { // POST /profile // {"full_name": "John Doe", "phone_number": "+1234567890", "bio": "Developer"} // // $profileData = [ // 'name' => 'John Doe', // field 'full_name' renamed to 'name' // 'phone' => '+1234567890', // field 'phone_number' renamed to 'phone' // 'bio' => 'Developer' // kept as is // ] return $this->profileService->update($profileData); } // Direct mapping to DTO public function createProduct( #[MapRequestPayload] CreateProductRequest $request, #[RequestAttribute] User $currentUser ): ResponseInterface { // JSON payload automatically converted to CreateProductRequest // through MiddlewareFactory using DI container $product = $this->productService->create($request, $currentUser); return new JsonResponse(['product' => $product], 201); } // Complex mapping with validation public function processOrder( #[MapRequestPayload([ 'customer_info' => 'customer', 'payment_method' => 'payment', 'shipping_address' => 'shipping' ])] ProcessOrderRequest $orderRequest ): ResponseInterface { // Complex nested data automatically mapped to structured DTO $order = $this->orderService->process($orderRequest); return new JsonResponse(['order' => $order], 201); }
Request attributes
The #[RequestAttribute]
attribute extracts data set by previous middleware:
use Bermuda\MiddlewareFactory\Attribute\RequestAttribute; public function getProfile( #[RequestAttribute] User $currentUser, // $request->getAttribute('currentUser') #[RequestAttribute('route.id')] int $userId // $request->getAttribute('route.id') ): ResponseInterface { // Attributes usually set by previous middleware // e.g., authentication or routing if ($currentUser->getId() !== $userId && !$currentUser->isAdmin()) { return new JsonResponse(['error' => 'Access denied'], 403); } $profile = $this->userService->getProfile($userId); return new JsonResponse(['profile' => $profile]); } // Working with optional attributes public function processWithContext( #[RequestAttribute('request.id')] string $requestId, #[RequestAttribute('trace.id')] ?string $traceId = null, #[RequestAttribute('feature.flags')] array $featureFlags = [] ): ResponseInterface { $context = [ 'request_id' => $requestId, 'trace_id' => $traceId, 'features' => $featureFlags ]; return $this->processWithContext($context); }
Combining different data sources
public function complexOperation( #[MapQueryParameter] int $page = 1, #[MapQueryParameter] int $limit = 10, #[MapRequestPayload(['filter_data' => 'filters'])] array $filters, #[RequestAttribute] User $currentUser, #[RequestAttribute('route.resource')] string $resource, #[Config('app.max_results')] int $maxResults, #[Inject('search.service')] SearchService $searchService ): ResponseInterface { // Data from different sources automatically combined // Query: ?page=2&limit=20 // Body: {"filter_data": {"category": "electronics", "price_min": 100}} // Attributes: currentUser, route.resource // Config: app.max_results // DI: SearchService $limit = min($limit, $maxResults); // Limit by configuration $searchParams = [ 'page' => $page, 'limit' => $limit, 'filters' => $filters, 'user_id' => $currentUser->getId(), 'resource' => $resource ]; $results = $searchService->search($searchParams); return new JsonResponse([ 'results' => $results, 'pagination' => [ 'page' => $page, 'limit' => $limit, 'total' => $results->getTotal() ] ]); }
Mapping error handling
// Middleware automatically handles mapping errors public function handleErrors( #[MapQueryParameter] int $requiredParam, // Required parameter #[MapRequestPayload] ValidatedRequest $request // DTO with validation ): ResponseInterface { // If requiredParam is missing from query string: // → OutOfBoundsException → ParameterResolutionException // // If request body cannot be converted to ValidatedRequest: // → corresponding exception from MiddlewareFactory return new JsonResponse(['success' => true]); } // Custom error handling $errorHandlingMiddleware = $factory->makeMiddleware(function( ServerRequestInterface $request, callable $next ): ResponseInterface { try { return $next($request); } catch (ParameterResolutionException $e) { return new JsonResponse([ 'error' => 'Invalid request parameters', 'details' => $e->getMessage(), 'parameter' => $e->parameter->getName() ], 400); } catch (MiddlewareResolutionException $e) { return new JsonResponse([ 'error' => 'Request processing failed', 'details' => $e->getMessage() ], 500); } });
Configuration and DI container
use Bermuda\DI\Attribute\Config; use Bermuda\DI\Attribute\Inject; public function processPayment( #[Config('payment.api_key')] string $apiKey, // From configuration #[Config('payment.timeout', 30)] int $timeout, // With default value #[Inject('payment.gateway')] PaymentGateway $gateway, // Service by name #[Inject('logger')] LoggerInterface $logger, // Logger from container PaymentService $paymentService, // Automatically by type #[MapRequestPayload] PaymentRequest $request // Data from request ): ResponseInterface { $logger->info("Processing payment with timeout: {$timeout}s"); $result = $gateway->processPayment($request, [ 'api_key' => $apiKey, 'timeout' => $timeout ]); return new JsonResponse(['result' => $result]); }
Complex example with configuration
public function handleUpload( #[Config('upload.max_size')] int $maxSize, // Maximum file size #[Config('upload.allowed_types', ['jpg', 'png'])] array $types, // Allowed types #[Config('storage.path')] string $storagePath, // Storage path #[Inject('file.validator')] FileValidator $validator, // File validator #[Inject('storage.manager')] StorageManager $storage, // Storage manager #[MapRequestPayload] UploadRequest $uploadData, // Upload data #[RequestAttribute] User $currentUser // Current user ): ResponseInterface { // File size validation if ($uploadData->getFileSize() > $maxSize) { return new JsonResponse(['error' => 'File too large'], 413); } // File type validation if (!in_array($uploadData->getFileType(), $types)) { return new JsonResponse(['error' => 'Invalid file type'], 415); } // Additional validation if (!$validator->validate($uploadData)) { return new JsonResponse(['error' => 'File validation failed'], 422); } // Save file $savedFile = $storage->store($uploadData, $storagePath, $currentUser); return new JsonResponse(['file' => $savedFile], 201); }
Advanced Usage
Creating custom strategy
use Bermuda\MiddlewareFactory\Strategy\StrategyInterface; use Psr\Http\Server\MiddlewareInterface; class CustomStrategy implements StrategyInterface { public function makeMiddleware(mixed $middleware): ?MiddlewareInterface { if ($middleware instanceof MyCustomType) { return new MyCustomAdapter($middleware); } return null; // Cannot handle this type } } // Register custom strategy $factory->addStrategy(new CustomStrategy(), true); // true = add to beginning
Configuration through container
use Bermuda\MiddlewareFactory\ConfigProvider; // In container configuration return [ ConfigProvider::CONFIG_KEY_STRATEGIES => [ CustomStrategy::class, AnotherStrategy::class ] ];
Error handling
use Bermuda\MiddlewareFactory\MiddlewareResolutionException; try { $middleware = $factory->makeMiddleware($invalidDefinition); } catch (MiddlewareResolutionException $e) { echo "Failed to create middleware: " . $e->getMessage(); echo "Middleware type: " . get_debug_type($e->middleware); // Get information about nested error if ($e->getPrevious()) { echo "Cause: " . $e->getPrevious()->getMessage(); } }
Architecture
Main components
- MiddlewareFactory - Main factory coordinating strategies
- Strategy - Interface for middleware resolution strategies
- Adapter - Adapters for converting types to PSR-15
- Resolver - Resolvers for dependency injection
- Attribute - PHP 8+ attributes for declarative mapping
Execution flow
- Middleware resolution - Factory tries strategies in order
- Adaptation - Converting to PSR-15 MiddlewareInterface
- Dependency injection - Resolving parameters through container
- Data mapping - Extracting data from request by attributes
- Execution - Calling middleware with prepared parameters
Real-world examples
REST API controller
use Bermuda\DI\Attribute\Config; use Bermuda\DI\Attribute\Inject; class ProductController { public function list( #[MapQueryParameter] int $page = 1, #[MapQueryParameter('per_page')] int $perPage = 10, #[MapQueryString(['sort' => 'sortBy', 'filter' => 'filters'])] array $query = [], #[Config('products.default_limit', 50)] int $maxLimit, // Maximum limit from config #[Inject('product.search')] ProductSearchService $search // Search service ): ResponseInterface { // Limit restriction $perPage = min($perPage, $maxLimit); $products = $search->paginate($page, $perPage, $query); return new JsonResponse(['data' => $products]); } public function create( #[MapRequestPayload] CreateProductRequest $request, #[RequestAttribute] User $currentUser, #[Config('products.auto_publish')] bool $autoPublish, // Auto-publish #[Inject('product.factory')] ProductFactory $factory, // Product factory #[Inject('event.dispatcher')] EventDispatcher $events // Event dispatcher ): ResponseInterface { $product = $factory->create($request, $currentUser); if ($autoPublish) { $product->publish(); } $this->productService->save($product); // Dispatch event $events->dispatch(new ProductCreated($product)); return new JsonResponse(['product' => $product], 201); } public function update( #[RequestAttribute('route.id')] int $id, #[MapRequestPayload] UpdateProductRequest $request, #[Config('products.versioning.enabled')] bool $versioningEnabled, // Versioning #[Inject('product.versioning')] ?VersioningService $versioning = null ): ResponseInterface { $product = $this->productService->findById($id); if ($versioningEnabled && $versioning) { $versioning->createSnapshot($product); } $product = $this->productService->update($product, $request); return new JsonResponse(['product' => $product]); } public function uploadImage( #[RequestAttribute('route.id')] int $productId, #[MapRequestPayload] UploadImageRequest $uploadRequest, #[Config('upload.images.max_size')] int $maxSize, // Maximum size #[Config('upload.images.quality', 85)] int $quality, // Compression quality #[Config('cdn.base_url')] string $cdnUrl, // CDN URL #[Inject('image.processor')] ImageProcessor $processor, // Image processor #[Inject('cdn.uploader')] CdnUploader $uploader // CDN uploader ): ResponseInterface { $product = $this->productService->findById($productId); // Process image $processedImage = $processor->process($uploadRequest->getFile(), [ 'max_size' => $maxSize, 'quality' => $quality ]); // Upload to CDN $cdnPath = $uploader->upload($processedImage, "products/{$productId}"); $imageUrl = $cdnUrl . '/' . $cdnPath; // Save URL to product $product->addImage($imageUrl); $this->productService->save($product); return new JsonResponse(['image_url' => $imageUrl]); } }
Middleware with dependency injection
use Bermuda\DI\Attribute\Config; use Bermuda\DI\Attribute\Inject; $authMiddleware = $factory->makeMiddleware(function( ServerRequestInterface $request, #[Inject('auth.service')] AuthService $auth, #[Config('auth.token_header', 'Authorization')] string $tokenHeader, #[Config('auth.token_prefix', 'Bearer ')] string $tokenPrefix, #[MapQueryParameter] ?string $token = null ): ResponseInterface|ServerRequestInterface { // Extract token from various sources $token = $token ?? str_replace($tokenPrefix, '', $request->getHeaderLine($tokenHeader)) ?? $request->getCookieParams()['auth_token'] ?? null; if (!$token || !$auth->validateToken($token)) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $user = $auth->getUserByToken($token); return $request->withAttribute('currentUser', $user); }); $rateLimitMiddleware = $factory->makeMiddleware(function( ServerRequestInterface $request, #[Config('rate_limit.requests_per_minute')] int $maxRequests, #[Config('rate_limit.window_seconds', 60)] int $windowSeconds, #[Inject('cache.redis')] RedisInterface $redis, #[Inject('rate_limiter')] RateLimiter $limiter, #[RequestAttribute] ?User $user = null ): ?ResponseInterface { $clientId = $user?->getId() ?? $request->getClientIp(); $key = "rate_limit:{$clientId}"; if ($limiter->isExceeded($key, $maxRequests, $windowSeconds)) { return new JsonResponse([ 'error' => 'Rate limit exceeded', 'retry_after' => $windowSeconds ], 429); } return null; // Continue execution }); $loggingMiddleware = $factory->makeMiddleware(function( ServerRequestInterface $request, #[Config('logging.enabled')] bool $loggingEnabled, #[Config('logging.level', 'info')] string $logLevel, #[Config('logging.include_headers', false)] bool $includeHeaders, #[Inject('logger')] LoggerInterface $logger, callable $next ): ResponseInterface { $startTime = microtime(true); if ($loggingEnabled) { $context = [ 'method' => $request->getMethod(), 'uri' => (string) $request->getUri(), 'user_agent' => $request->getHeaderLine('User-Agent') ]; if ($includeHeaders) { $context['headers'] = $request->getHeaders(); } $logger->log($logLevel, 'Request started', $context); } $response = $next($request); if ($loggingEnabled) { $duration = (microtime(true) - $startTime) * 1000; $logger->log($logLevel, 'Request completed', [ 'status' => $response->getStatusCode(), 'duration_ms' => round($duration, 2) ]); } return $response; });
Complex data processing
use Bermuda\DI\Attribute\Config; use Bermuda\DI\Attribute\Inject; $processingMiddleware = $factory->makeMiddleware(function( #[MapRequestPayload] array $data, #[Config('processing.strict_mode')] bool $strictMode, #[Config('processing.rules')] array $globalRules, #[Config('processing.max_errors', 10)] int $maxErrors, #[Inject('data.processor')] DataProcessorInterface $processor, #[Inject('rule.factory')] RuleFactory $ruleFactory, #[RequestAttribute('route.action')] string $action, #[RequestAttribute] ?User $user = null ): ?ResponseInterface { // Get processing rules for specific action $actionRules = $globalRules[$action] ?? []; $rules = $ruleFactory->buildRules($actionRules, $user, $strictMode); // Execute processing $result = $processor->process($data, $rules, ['max_errors' => $maxErrors]); if (!$result->isValid()) { $errors = $result->getErrors(); // In strict mode return all errors if ($strictMode) { return new JsonResponse([ 'error' => 'Processing failed', 'errors' => $errors, 'strict_mode' => true ], 422); } // In normal mode return first N errors return new JsonResponse([ 'error' => 'Processing failed', 'errors' => array_slice($errors, 0, $maxErrors) ], 422); } return null; // Continue execution }); $cachingMiddleware = $factory->makeMiddleware(function( ServerRequestInterface $request, #[Config('cache.enabled')] bool $cacheEnabled, #[Config('cache.ttl', 3600)] int $cacheTtl, #[Config('cache.key_prefix', 'api')] string $keyPrefix, #[Inject('cache.redis')] CacheInterface $cache, callable $next ): ResponseInterface { if (!$cacheEnabled) { return $next($request); } // Generate cache key $cacheKey = $keyPrefix . ':' . md5($request->getUri() . serialize($request->getQueryParams())); // Try to get from cache $cachedResponse = $cache->get($cacheKey); if ($cachedResponse !== null) { return new JsonResponse($cachedResponse)->withHeader('X-Cache', 'HIT'); } // Execute request $response = $next($request); // Cache successful responses if ($response->getStatusCode() === 200) { $responseData = json_decode($response->getBody()->getContents(), true); $cache->set($cacheKey, $responseData, $cacheTtl); } return $response->withHeader('X-Cache', 'MISS'); });
Best Practices
1. Strategy order
Register more specific strategies first:
$factory->addStrategy(new CustomStrategy(), true); // First $factory->addStrategy(new ClassNameStrategy()); // Default $factory->addStrategy(new CallableStrategy()); // Default
2. Error handling
Always wrap middleware creation in try-catch:
try { $middleware = $factory->makeMiddleware($definition); } catch (MiddlewareResolutionException $e) { // Logging and error handling $logger->error('Middleware resolution failed', [ 'middleware' => $e->middleware, 'message' => $e->getMessage() ]); throw $e; }
3. Parameter typing
Use strict typing for automatic type conversion:
public function handle( #[MapQueryParameter] int $page, // Automatic cast to int #[MapQueryParameter] bool $active, // Automatic cast to bool #[MapRequestPayload] CreateUserRequest $request // Strict DTO typing ): ResponseInterface { // Guaranteed correct types }
4. Documenting attributes
Document used attributes for clarity:
/** * Creates a new user * * @param CreateUserRequest $request User data from request body * @param User $currentUser Current user from auth middleware * @param string $role Role from query parameter 'role' * @param int $maxUsers Maximum users from configuration * @param UserFactory $factory User factory from DI container */ public function createUser( #[MapRequestPayload] CreateUserRequest $request, #[RequestAttribute] User $currentUser, #[MapQueryParameter] string $role = 'user', #[Config('users.max_count', 1000)] int $maxUsers, #[Inject('user.factory')] UserFactory $factory ): ResponseInterface { // ... }
5. Configuration usage
Group related settings and use default values:
public function processImage( #[Config('image.processing.max_width', 1920)] int $maxWidth, #[Config('image.processing.max_height', 1080)] int $maxHeight, #[Config('image.processing.quality', 85)] int $quality, #[Config('image.processing.format', 'webp')] string $format, #[Inject('image.processor')] ImageProcessor $processor ): ResponseInterface { // Settings with reasonable defaults }
6. Service injection
Use meaningful service names in the container:
// Good - clear service names #[Inject('payment.stripe.gateway')] StripeGateway $stripe, #[Inject('payment.paypal.gateway')] PayPalGateway $paypal, #[Inject('notification.email')] EmailService $emailService, #[Inject('notification.sms')] SmsService $smsService // Bad - unclear names #[Inject('service1')] SomeService $service, #[Inject('gateway')] Gateway $gateway
Performance
The factory is optimized for performance:
- Lazy creation - Middleware created only when needed
- Strategy caching - Strategies registered once
- Minimal reflection - Reflection used only for signature analysis
- Efficient adaptation - Minimal overhead when adapting types
Testing
use PHPUnit\Framework\TestCase; use Bermuda\DI\Attribute\Config; use Bermuda\DI\Attribute\Inject; use Bermuda\MiddlewareFactory\MiddlewareGroup; use Bermuda\MiddlewareFactory\Strategy\MiddlewarePipelineStrategy; class MiddlewareFactoryTest extends TestCase { public function testCreateMiddlewareFromCallable(): void { $factory = MiddlewareFactory::createFromContainer($this->container); $callable = function(ServerRequestInterface $request, callable $next): ResponseInterface { return $next($request); }; $middleware = $factory->makeMiddleware($callable); $this->assertInstanceOf(MiddlewareInterface::class, $middleware); } public function testParameterMapping(): void { $middleware = $this->factory->makeMiddleware(function( #[MapQueryParameter] string $name ): ResponseInterface { return new JsonResponse(['name' => $name]); }); $request = $this->createRequest('GET', '/?name=John'); $response = $middleware->process($request, $this->handler); $this->assertEquals(['name' => 'John'], json_decode($response->getBody(), true)); } public function testConfigAndInjectAttributes(): void { // Setup container for testing $this->container->set('config', new ArrayObject([ 'app' => ['name' => 'Test App', 'debug' => true] ])); $this->container->set('test.service', new TestService()); $middleware = $this->factory->makeMiddleware(function( #[Config('app.name')] string $appName, #[Config('app.debug')] bool $debug, #[Config('app.timeout', 30)] int $timeout, #[Inject('test.service')] TestService $service, #[MapQueryParameter] string $action ): ResponseInterface { return new JsonResponse([ 'app_name' => $appName, 'debug' => $debug, 'timeout' => $timeout, 'service_id' => $service->getId(), 'action' => $action ]); }); $request = $this->createRequest('GET', '/?action=test'); $response = $middleware->process($request, $this->handler); $data = json_decode($response->getBody(), true); $this->assertEquals('Test App', $data['app_name']); $this->assertTrue($data['debug']); $this->assertEquals(30, $data['timeout']); // default value $this->assertEquals('test-service-123', $data['service_id']); $this->assertEquals('test', $data['action']); } public function testMiddlewareGroup(): void { $group = new MiddlewareGroup([ function($request, $next) { return $next($request); }, 'test.middleware' ]); $middleware = $this->factory->makeMiddleware($group); $this->assertInstanceOf(MiddlewareInterface::class, $middleware); } public function testMiddlewareFactoryAwareStrategy(): void { $strategy = new MiddlewarePipelineStrategy(); // Strategy should receive factory when added $this->factory->addStrategy($strategy); // Create middleware group $group = new MiddlewareGroup(['test.middleware']); // Strategy should successfully handle group $middleware = $strategy->makeMiddleware($group); $this->assertInstanceOf(MiddlewareInterface::class, $middleware); } public function testStrategyWithoutFactoryThrowsException(): void { $strategy = new MiddlewarePipelineStrategy(); // Without factory $group = new MiddlewareGroup(['test.middleware']); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('MiddlewareFactory is required to convert MiddlewareGroup to Pipeline'); $strategy->makeMiddleware($group); } public function testNestedConfigAccess(): void { $this->container->set('config', new ArrayObject([ 'database' => [ 'connections' => [ 'mysql' => ['host' => 'localhost', 'port' => 3306], 'redis' => ['host' => '127.0.0.1', 'port' => 6379] ] ] ])); $middleware = $this->factory->makeMiddleware(function( #[Config('database.connections.mysql.host')] string $mysqlHost, #[Config('database.connections.redis.port')] int $redisPort ): ResponseInterface { return new JsonResponse([ 'mysql_host' => $mysqlHost, 'redis_port' => $redisPort ]); }); $request = $this->createRequest('GET', '/'); $response = $middleware->process($request, $this->handler); $data = json_decode($response->getBody(), true); $this->assertEquals('localhost', $data['mysql_host']); $this->assertEquals(6379, $data['redis_port']); } }
License
MIT License