solophp/request-handler

Robust request validation & authorization layer for HTTP inputs with type-safe handlers and modern PHP 8+ architecture

v1.0.0 2025-09-14 15:54 UTC

This package is auto-updated.

Last update: 2025-09-14 15:55:27 UTC


README

Latest Version on Packagist License PHP Version

Robust request validation & authorization layer for HTTP inputs with type-safe handlers and modern PHP 8+ architecture

How It Works

Fields are conditionally processed based on their presence in requests and default values:

  • Present in request → Always included in results
  • Missing + has default() → Included with default value
  • Missing + no default → Excluded from results entirely
  • Validation → Only runs on present fields or those marked as required

✨ Features

  • Smart field inclusion - only processes relevant fields
  • Type-safe processing with readonly properties and strict types
  • Multi-stage pipeline (extractauthorizepreprocessvalidatepostprocess)
  • Field mapping from nested structures via dot notation
  • Vendor-independent validation - use any validator
  • PSR-7 compatible HTTP message interface
  • Built-in authorization with simple overrides

🔗 Dependencies

Suggested Validators

📥 Installation

composer require solophp/request-handler

🚀 Quick Start

Define a Request Handler

<?php declare(strict_types=1);

namespace App\Requests;

use Solo\RequestHandler\AbstractRequestHandler;
use Solo\RequestHandler\Field;

final class CreateArticleRequest extends AbstractRequestHandler 
{
    protected function fields(): array 
    {
        return [
            Field::for('author_email')
                ->mapFrom('meta.author.email')
                ->validate('required|email'),
                
            Field::for('title')
                ->validate('required|string|max:100')
                ->preprocess(fn(mixed $value): string => trim((string)$value)),
            
            Field::for('status')
                ->default('draft')
                ->validate('string|in:draft,published')
                ->postprocess(fn(mixed $value): string => strtoupper((string)$value))
        ];
    }

    protected function authorize(): bool 
    {
        return $this->user()->can('create', Article::class);
    }
}

Handle in Controller

<?php declare(strict_types=1);

namespace App\Controllers;

use App\Requests\CreateArticleRequest;
use Solo\RequestHandler\Exceptions\{ValidationException, AuthorizationException};

final class ArticleController 
{
    public function store(ServerRequestInterface $request, CreateArticleRequest $articleRequest): array
    {
        try {
            $data = $articleRequest->handle($request);
            Article::create($data);
            return ['success' => true, 'data' => $data];
        } catch (ValidationException $e) {
            return ['errors' => $e->getErrors()];
        } catch (AuthorizationException $e) {
            return ['message' => $e->getMessage(), 'code' => 403];
        }
    }
}

⚙️ Field Configuration

Method Required? Description
Field::for(string) Yes Starts field definition
mapFrom(string) No Map input from custom name/nested path
default(mixed) No Fallback value if field is missing
validate(string) No Validation rules (e.g., `required
preprocess(callable) No Transform raw input before validation
postprocess(callable) No Modify value after validation
hasDefault() No Check if field has explicit default value

Processing Pipeline

  1. Extract Data - Merge POST body and GET parameters (body priority)
  2. Authorize - Check user permissions via authorize() method
  3. Map Input - Resolve values using mapFrom paths with dot notation
  4. Preprocess - Clean and transform raw input data
  5. Validate - Check against validation rules with custom messages
  6. Postprocess - Apply final value transformations and formatting

Advanced Example

Field::for('categories')
    ->mapFrom('meta.category_list')
    ->preprocess(fn(mixed $value): array => 
        is_string($value) ? explode(',', $value) : (array)$value
    )
    ->validate('array|min:1|max:10')
    ->postprocess(fn(array $value): array => 
        array_map('intval', array_unique($value))
    )

🏗️ Architecture Overview

The system employs a modular architecture with clear separation of concerns:

RequestProcessor - Central coordinator managing the complete processing pipeline with dependency injection for all components.

DataExtractor - Handles data extraction from requests, field mapping via dot notation, and preprocessing/postprocessing transformations.

Authorizer - Manages authorization checks through simple interface integration with existing access control systems.

DataValidator - Provides validation services with Solo Validator integration and comprehensive error message support.

🔄 Request Data Handling

  • Nested Structures: Use dot notation (mapFrom('user.profile.contact.email'))
  • GET: Query parameters only
  • POST/PUT/PATCH: Merged body and query parameters (body takes priority)
  • Files: Access via $request->getUploadedFiles() with PSR-7 compatibility

⚡ Error Handling

ValidationException (HTTP 422)

catch (ValidationException $e) {
    return ['errors' => $e->getErrors()]; // Format: ['field' => ['Error message']]
}

AuthorizationException (HTTP 403)

catch (AuthorizationException $e) {
    return ['message' => $e->getMessage()]; // "Unauthorized request"
}

🚦 Custom Messages

protected function messages(): array 
{
    return [
        'author_email.required' => 'Author email is required for article creation',
        'author_email.email' => 'Please provide a valid email address',
        'status.in' => 'Status must be either draft or published',
        'title.max' => 'Article title must not exceed :max characters'
    ];
}

🗂️ Repository Integration Helpers

The AbstractRequestHandler includes built-in helper methods for common API patterns like sorting and filtering, designed to work seamlessly with repository patterns.

Index/List Request Pattern

<?php declare(strict_types=1);

namespace App\Requests;

use Solo\RequestHandler\AbstractRequestHandler;
use Solo\RequestHandler\Field;

final readonly class UserIndexRequest extends AbstractRequestHandler
{
    protected function fields(): array
    {
        return [
            Field::for('page')
                ->default(1)
                ->validate('integer|min:1')
                ->postprocess(fn($v) => (int)$v),
            
            Field::for('per_page')
                ->default(15)
                ->validate('integer|min:1|max:100')
                ->postprocess(fn($v) => (int)$v),
                
            Field::for('sort')
                ->postprocess(fn($v) => $this->parseSortParameter($v)),
                
            Field::for('filter')
                ->postprocess(fn($v) => $this->parseFilterParameter($v)),
        ];
    }
}

Helper Methods

parseSortParameter(?string $sort): ?array

Converts sort parameter from URL format to repository format:

  • ?sort=name['name' => 'ASC']
  • ?sort=-created_at['created_at' => 'DESC']

parseFilterParameter($filter): array

Parses filter parameter for repository filtering:

  • ?filter[status]=active&filter[role]=admin['filter' => ['status' => 'active', 'role' => 'admin']]

Usage in Controllers

<?php declare(strict_types=1);

namespace App\Controllers;

use App\Requests\UserIndexRequest;
use App\Repositories\UserRepository;

final class UserController
{
    public function index(ServerRequestInterface $request, UserIndexRequest $indexRequest): array
    {
        $data = $indexRequest->handle($request);
        
        // Clean, validated data ready for repository
        $users = $this->userRepository->getBy(
            criteria: $data['filter'],    // ['filter' => [...]] or []
            orderBy: $data['sort'],       // ['field' => 'ASC/DESC'] or null
            perPage: $data['per_page'],   // int
            page: $data['page']           // int
        );
        
        $total = $this->userRepository->countBy($data['filter']);
        
        return $this->paginate($users, $data['page'], $data['per_page'], $total);
    }
}

URL Examples

# Basic pagination
GET /users?page=2&per_page=25

# Sorting (ascending)
GET /users?sort=created_at

# Sorting (descending)
GET /users?sort=-name

# Filtering
GET /users?filter[status]=active&filter[role]=admin

# Combined
GET /users?sort=-created_at&filter[status]=active&page=2&per_page=10

📚 Public API

Method Description
handle(ServerRequestInterface $request): array Main entry point: processes complete request pipeline
getFields(): array Returns field definitions for the handler
getMessages(): array Returns custom validation error messages
isAuthorized(): bool Checks authorization status for the request

🔧 Advanced Usage

Component Customization with Factory Methods

For advanced use cases, you can customize individual components by overriding factory methods:

final class ApiArticleRequest extends AbstractRequestHandler
{
    // Custom authorization logic
    protected function createAuthorizer(): AuthorizerInterface
    {
        return new ApiTokenAuthorizer();
    }
    
    // Custom data extraction for JSON API
    protected function createDataExtractor(): DataExtractorInterface
    {
        return new JsonApiDataExtractor();
    }

    protected function fields(): array
    {
        return [
            Field::for('data')->mapFrom('json.data')->validate('required|array')
        ];
    }
}

⚙️ Requirements

  • PHP 8.2+ with strict typing support
  • PSR-7 HTTP Message Interface for request/response handling
  • Validator implementing Solo\Contracts\Validator\ValidatorInterface

🎯 Performance Features

  • Readonly Properties - Optimal opcache performance with immutable objects
  • Minimal Memory Footprint - Efficient dependency injection patterns
  • Interface-based Design - Clean architecture with separated concerns
  • Component Reuse - Efficient object creation with factory method pattern

📄 License

MIT License - See LICENSE for complete terms and conditions.