Requests and DTOs Guide

Overview

The Neuron CMS provides a robust request handling system that integrates Data Transfer Objects (DTOs) for validated, type-safe data processing. This integration allows controllers to define request schemas in YAML configuration files, automatically populate DTOs from incoming requests, and perform comprehensive validation before processing.

The request/DTO system provides:

Architecture

Core Components

The request/DTO system consists of the following classes:

Request Flow

The typical request processing flow:

  1. Request arrives - HTTP request hits controller endpoint
  2. Schema loading (optional) - Request object loads YAML configuration defining expected structure
  3. DTO creation - Factory creates DTO from configuration or programmatically
  4. Population - Request data populates DTO properties
  5. Validation - DTO validates all properties against defined rules
  6. Processing - Controller processes validated data from DTO
  7. Response - Controller returns response or validation errors

Request Class

Basic Request Methods

The Neuron\Mvc\Requests\Request class provides filtered access to request data:

use Neuron\Mvc\Requests\Request;

$request = new Request();

// GET parameters (filtered)
$page = $request->get( 'page', 1 );
$search = $request->get( 'search' );

// POST parameters (filtered)
$username = $request->post( 'username' );
$email = $request->post( 'email' );

// SERVER parameters
$userAgent = $request->server( 'HTTP_USER_AGENT' );
$method = $request->server( 'REQUEST_METHOD' );

// SESSION parameters
$userId = $request->session( 'user_id' );

// COOKIE parameters
$token = $request->cookie( 'remember_token' );

// Client IP address
$clientIp = $request->getClientIp();

// JSON payload
$payload = $request->getJsonPayload();

Route Parameters

Route parameters extracted from URL patterns:

// Route: /posts/:id/comments/:comment_id
$postId = $request->getRouteParameter( 'id' );
$commentId = $request->getRouteParameter( 'comment_id' );

// All route parameters
$params = $request->getRouteParameters();

Request Method

use Neuron\Routing\RequestMethod;

$method = $request->getRequestMethod();

if( $method === RequestMethod::POST )
{
    // Handle POST request
}

Request YAML Configuration

Basic Structure

Request schemas are defined in YAML files with inline DTO properties:

request:
  method: POST
  headers:
    Content-Type: application/json
  properties:
    propertyName:
      type: string|integer|boolean|array|object
      required: true|false
      # Additional validation rules

Complete Example

Create config/requests/login.yaml:

request:
  method: POST
  headers:
    Content-Type: application/json
  properties:
    username:
      required: true
      type: string
      length:
        min: 3
        max: 20
    password:
      required: true
      type: string
      length:
        min: 8
        max: 50
    remember_me:
      type: boolean

Nested Objects

Request schemas support nested DTOs:

request:
  method: POST
  properties:
    username:
      required: true
      type: string
      length:
        min: 3
        max: 20
    email:
      required: true
      type: email
    address:
      required: true
      type: object
      properties:
        street:
          required: true
          type: string
          length:
            min: 5
            max: 100
        city:
          required: true
          type: string
        state:
          required: true
          type: string
          length:
            min: 2
            max: 2
        zip:
          required: true
          type: string
          pattern: '/^\d{5}(-\d{4})?$/'

Arrays of Objects

request:
  method: POST
  properties:
    user:
      type: object
      required: true
      properties:
        name:
          type: string
          required: true
        email:
          type: email
          required: true
    phone_numbers:
      type: array
      items:
        type: object
        properties:
          type:
            type: string
            enum: ['home', 'work', 'mobile']
            required: true
          number:
            type: string
            pattern: '/^\+?[\d\s\-\(\)]+$/'
            required: true

Loading Request Configuration

use Neuron\Mvc\Requests\Request;

$request = new Request();
$request->loadFile( 'config/requests/login.yaml' );

// Access configured DTO
$dto = $request->getDto();

// Process payload against DTO
$payload = [
    'username' => 'johndoe',
    'password' => 'securepass123',
    'remember_me' => true
];

$request->processPayload( $payload );

DTO Integration

Automatic Population

The Request class automatically populates DTOs from request data:

// Request with inline DTO
$request = new Request();
$request->loadFile( 'config/requests/registration.yaml' );

// Populate from POST data
$request->processPayload( $_POST );

// Access populated DTO
$dto = $request->getDto();

echo $dto->username;  // 'johndoe'
echo $dto->email;     // '[email protected]'
echo $dto->address->street;  // '123 Main St'

Manual DTO Creation

DTOs can be created programmatically without YAML:

use Neuron\Dto\Factory;

// Create DTO from array definition
$factory = new Factory( [
    'username' => [
        'type' => 'string',
        'required' => true,
        'length' => ['min' => 3, 'max' => 20]
    ],
    'email' => [
        'type' => 'email',
        'required' => true
    ],
    'age' => [
        'type' => 'integer',
        'range' => ['min' => 18, 'max' => 120]
    ]
] );

$dto = $factory->create();

// Set values
$dto->username = 'johndoe';
$dto->email = '[email protected]';
$dto->age = 30;

// Validate
try {
    $dto->validate();
} catch( \Neuron\Core\Exceptions\Validation $e )
{
    $errors = $e->errors;
}

Validation

DTOs validate automatically when processPayload() is called:

try {
    $request->processPayload( $_POST );

    // Validation passed - safe to use DTO
    $dto = $request->getDto();

} catch( \Neuron\Core\Exceptions\Validation $e )
{
    // Validation failed
    $errors = $request->getErrors();

    foreach( $errors as $error )
{
        echo "Error: $error\n";
    }
}

Controller Patterns

UsesDtos Trait

The UsesDtos trait provides helper methods for DTO operations in controllers:

use Neuron\Cms\Controllers\Traits\UsesDtos;
use Neuron\Mvc\Controller;

class RegistrationController extends Controller
{
    use UsesDtos;

    // Trait methods now available
}

Trait Methods

// Get DTO factory service
protected function getDtoFactory(): DtoFactoryService

// Populate DTO from request POST data
protected function populateDtoFromRequest( Dto $dto,
    Request $request,
    array $fields = [] ): Dto

// Validate DTO and return errors (empty if valid)
protected function validateDto( Dto $dto ): array

// Validate DTO and throw exception if invalid
protected function validateDtoOrFail( Dto $dto ): void

// Create and populate DTO from request in one step
protected function createDtoFromRequest( string $name,
    Request $request,
    array $fields = [] ): Dto

Creating DTOs with DtoFactoryService

The DTO factory service looks for DTO YAML files in standard locations:

// Expects config/dtos/RegisterUser.yaml
$dto = $this->getDtoFactory()->create( 'RegisterUser' );

// Populate from request
$dto = $this->populateDtoFromRequest( $dto, $request );

// Or create and populate in one step
$dto = $this->createDtoFromRequest( 'RegisterUser', $request );

DTO File Organization

DTOs are stored in config/dtos/ directory:

config/
├── dtos/
│   ├── RegisterUser.yaml
│   ├── LoginUser.yaml
│   ├── CreatePost.yaml
│   ├── UpdateProfile.yaml
│   └── ContactForm.yaml
└── requests/
    ├── login.yaml
    └── registration.yaml

Practical Examples

Login Form

DTO configuration (config/dtos/LoginUser.yaml):

dto:
  username:
    type: string
    required: true
    length:
      min: 3
      max: 50
  password:
    type: string
    required: true
    length:
      min: 8
      max: 255
  remember_me:
    type: boolean

Controller:

namespace App\Controllers;

use Neuron\Cms\Controllers\Content;
use Neuron\Cms\Controllers\Traits\UsesDtos;
use Neuron\Mvc\Requests\Request;

class LoginController extends Content
{
    use UsesDtos;

    public function processLogin( Request $request ): never
    {
        try {
            // Create and populate DTO from request
            $dto = $this->createDtoFromRequest( 'LoginUser', $request );

            // Validate DTO (throws exception if invalid)
            $this->validateDtoOrFail( $dto );

            // Use validated data
            $authenticated = $this->authManager->login( $dto->username,
                $dto->password,
                $dto->remember_me ?? false );

            if( $authenticated )
{
                $this->redirect( 'dashboard' );
            }
else {
                $this->redirect( 'login', [], ['error', 'Invalid credentials'] );
            }

        } catch( \Exception $e )
{
            $this->redirect( 'login', [], ['error', $e->getMessage()]);
        }
    }
}

Registration with Nested DTOs

DTO configuration (config/dtos/RegisterUser.yaml):

dto:
  username:
    type: string
    required: true
    length:
      min: 3
      max: 20
    pattern: '/^[a-zA-Z0-9_]+$/'
  email:
    type: email
    required: true
  password:
    type: string
    required: true
    length:
      min: 8
      max: 255
  password_confirmation:
    type: string
    required: true
  profile:
    type: object
    required: true
    properties:
      first_name:
        type: string
        required: true
        length:
          min: 2
          max: 50
      last_name:
        type: string
        required: true
        length:
          min: 2
          max: 50
      birth_date:
        type: date
      phone:
        type: string
        pattern: '/^\+?[\d\s\-\(\)]+$/'
  address:
    type: object
    properties:
      street:
        type: string
        length:
          min: 5
          max: 100
      city:
        type: string
      state:
        type: string
        length:
          min: 2
          max: 2
      zip:
        type: string
        pattern: '/^\d{5}(-\d{4})?$/'

Controller:

public function processRegistration( Request $request ): never
{
    try {
        // Create and populate DTO
        $dto = $this->createDtoFromRequest( 'RegisterUser', $request );

        // Validate DTO
        $this->validateDtoOrFail( $dto );

        // Additional custom validation
        if( $dto->password !== $dto->password_confirmation )
{
            throw new \Exception( 'Passwords do not match' );
        }

        // Create user account
        $user = $this->registrationService->register( $dto->username,
            $dto->email,
            $dto->password );

        // Update profile with nested data
        $user->setFirstName( $dto->profile->first_name );
        $user->setLastName( $dto->profile->last_name );

        if( $dto->address )
{
            $user->setAddress( [
                'street' => $dto->address->street,
                'city' => $dto->address->city,
                'state' => $dto->address->state,
                'zip' => $dto->address->zip
            ] );
        }

        $this->userRepository->update( $user );

        $this->redirect( 'verify_email_sent', [], [
            'success',
            'Registration successful! Please check your email.'
        ] );

    } catch( \Exception $e )
{
        $this->redirect( 'register', [], ['error', $e->getMessage()]);
    }
}

API Endpoint with JSON

Request configuration (config/requests/create-post.yaml):

request:
  method: POST
  headers:
    Content-Type: application/json
  properties:
    title:
      type: string
      required: true
      length:
        min: 5
        max: 200
    content:
      type: string
      required: true
      length:
        min: 50
    excerpt:
      type: string
      length:
        max: 500
    status:
      type: string
      enum: ['draft', 'published', 'scheduled']
      required: true
    category_ids:
      type: array
      items:
        type: integer
    tags:
      type: array
      items:
        type: string
        length:
          min: 2
          max: 30

Controller:

namespace App\Controllers\Api;

use Neuron\Mvc\Controller;
use Neuron\Mvc\Requests\Request;

class PostsController extends Controller
{
    public function create( Request $request ): string
    {
        try {
            // Load request configuration with DTO
            $request->loadFile( 'config/requests/create-post.yaml' );

            // Get JSON payload
            $payload = $request->getJsonPayload();

            // Process and validate against DTO
            $request->processPayload( $payload );

            // Access validated DTO
            $dto = $request->getDto();

            // Create post
            $post = $this->postService->create( $dto->title,
                $dto->content,
                $dto->status,
                $dto->excerpt ?? null );

            // Attach categories and tags
            if (isset( $dto->category_ids )) {
                $this->postService->attachCategories( $post, $dto->category_ids );
            }

            if (isset( $dto->tags )) {
                $this->postService->attachTags( $post, $dto->tags );
            }

            return $this->renderJson( ['success' => true, 'post' => $post->toArray()],
                201 );

        } catch( \Neuron\Core\Exceptions\Validation $e )
{
            return $this->renderJson( ['success' => false, 'errors' => $e->errors],
                422 );
        } catch( \Exception $e )
{
            return $this->renderJson( ['success' => false, 'error' => $e->getMessage()],
                500 );
        }
    }
}

Form Error Handling

View template (HTML form with error display):

<form method="POST" action="/register">
    <?= csrf_field() ?>

    <?php if ($Error = $this->get( 'Error' )): ?>
        <div class="alert alert-danger">
            <?= htmlspecialchars( $Error ) ?>
        </div>
    <?php endif; ?>

    <div class="form-group">
        <label>Username</label>
        <input type="text" name="username"
               value="<?= htmlspecialchars( $_POST['username'] ?? '' ) ?>"
               class="form-control" required>
    </div>

    <div class="form-group">
        <label>Email</label>
        <input type="email" name="email"
               value="<?= htmlspecialchars( $_POST['email'] ?? '' ) ?>"
               class="form-control" required>
    </div>

    <div class="form-group">
        <label>Password</label>
        <input type="password" name="password"
               class="form-control" required>
    </div>

    <button type="submit" class="btn btn-primary">Register</button>
</form>

Controller with detailed error handling:

public function processRegistration( Request $request ): never
{
    try {
        // Create and populate DTO
        $dto = $this->createDtoFromRequest( 'RegisterUser', $request );

        // Get validation errors (non-throwing)
        $errors = $this->validateDto( $dto );

        if (!empty( $errors )) {
            // Format errors for display
            $errorMessage = implode( ' ', array_map(function($error ) {
                return ucfirst( $error );
            }, $errors));

            $this->redirect( 'register', [], ['error', $errorMessage] );
        }

        // Process registration
        $this->registrationService->registerWithDto( $dto );

        $this->redirect( 'verify_email_sent', [], [
            'success',
            'Registration successful! Please check your email.'
        ] );

    } catch( \Exception $e )
{
        $this->redirect( 'register', [], ['error', $e->getMessage()]);
    }
}

Validation

DTO Property Types

DTOs support comprehensive type validation:

Type Description Validation
string Text values Length, pattern
integer Whole numbers Range, min, max
float Decimal numbers Range, precision
boolean True/false values Type checking
array Lists of items Item validation
object Nested objects Property validation
email Email addresses RFC compliance
url URLs URL format
date Date values Date format
date_time Date and time DateTime format
time Time values Time format
currency Money amounts Currency format
uuid UUIDs UUID v4 format
ip_address IP addresses IPv4/IPv6
us_phone_number US phone numbers US format
intl_phone_number International phones International format

Validation Rules

Length validation:

username:
  type: string
  length:
    min: 3
    max: 20

Range validation:

age:
  type: integer
  range:
    min: 18
    max: 120

Pattern validation:

zipcode:
  type: string
  pattern: '/^\d{5}(-\d{4})?$/'

Enum validation:

status:
  type: string
  enum: ['active', 'inactive', 'pending']

Error Collection

Validation errors are collected and accessible:

try {
    $dto->validate();
} catch( \Neuron\Core\Exceptions\Validation $e )
{
    // Exception contains errors array
    $errors = $e->errors;

    // Example errors:
    // [
    //     'username: length validation failed.',
    //     'email: email validation failed.',
    //     'age: range validation failed.'
    // ]
}

// Or from request
$request->processPayload( $_POST );
$errors = $request->getErrors();

Nested Validation

Nested DTOs validate recursively:

// Validates all properties including nested address object
$dto->validate();

// Errors from nested objects are included:
// [
//     'username: length validation failed.',
//     'address.street: length validation failed.',
//     'address.zip: pattern validation failed.'
// ]

Security Considerations

CSRF Protection

Always validate CSRF tokens for state-changing requests:

use Neuron\Cms\Services\Auth\CsrfToken;

public function processForm( Request $request ): never
{
    // Get CSRF token from request
    $token = $request->post( 'csrf_token' );

    // Validate token
    $csrfToken = new CsrfToken( $this->getSessionManager());

    if (!$csrfToken->validate( $token )) {
        $this->redirect( 'form', [], ['error', 'Invalid CSRF token'] );
    }

    // Process form...
}

Input Sanitization

The Request class filters all input automatically:

// All methods use filtered input
$username = $request->post( 'username' );  // Filtered
$email = $request->get( 'email' );         // Filtered

// Manual filtering if needed
$raw = $_POST['data'];
$filtered = filter_var( $raw, FILTER_SANITIZE_STRING );

Validation Before Processing

Always validate before using data:

// Good: Validate first
$dto = $this->createDtoFromRequest( 'CreatePost', $request );
$this->validateDtoOrFail( $dto );
$this->postService->create( $dto );

// Bad: Using data without validation
$dto = $this->createDtoFromRequest( 'CreatePost', $request );
$this->postService->create( $dto );  // Dangerous!

Enumeration Protection

Prevent user enumeration by returning generic messages:

public function resendVerification( Request $request ): never
{
    $email = $request->post( 'email' );

    try {
        $this->emailVerifier->resendVerification( $email );
    } catch( \Exception $e )
{
        // Don't reveal if email exists
    }

    // Always show same message
    $this->redirect( 'verify_sent', [], [
        'success',
        'If an account exists, a verification email has been sent.'
    ] );
}

Password Validation

Additional password validation beyond DTO:

public function processRegistration( Request $request ): never
{
    $dto = $this->createDtoFromRequest( 'RegisterUser', $request );
    $this->validateDtoOrFail( $dto );

    // Additional password checks
    if( $dto->password !== $dto->password_confirmation )
{
        throw new \Exception( 'Passwords do not match' );
    }

    if (strlen( $dto->password ) < 12) {
        throw new \Exception( 'Password must be at least 12 characters' );
    }

    // Check password strength
    if (!preg_match( '/[A-Z]/', $dto->password ) ||
        !preg_match( '/[a-z]/', $dto->password ) ||
        !preg_match( '/[0-9]/', $dto->password )) {
        throw new \Exception( 'Password must contain uppercase, lowercase, and numbers' );
    }

    // Process registration...
}

Best Practices

DTO Organization

Organize DTOs by functional area:

config/dtos/
├── auth/
│   ├── Login.yaml
│   ├── Register.yaml
│   └── ResetPassword.yaml
├── posts/
│   ├── CreatePost.yaml
│   ├── UpdatePost.yaml
│   └── PublishPost.yaml
├── users/
│   ├── CreateUser.yaml
│   ├── UpdateUser.yaml
│   └── UpdateProfile.yaml
└── contact/
    └── ContactForm.yaml

Reusable DTO Fragments

Define common structures for reuse:

# config/dtos/fragments/address.yaml
dto:
  street:
    type: string
    required: true
    length:
      min: 5
      max: 100
  city:
    type: string
    required: true
  state:
    type: string
    length:
      min: 2
      max: 2
  zip:
    type: string
    pattern: '/^\d{5}(-\d{4})?$/'

Validation Messages

Provide clear validation messages:

public function processForm( Request $request ): never
{
    try {
        $dto = $this->createDtoFromRequest( 'ContactForm', $request );
        $this->validateDtoOrFail( $dto );

    } catch( \Exception $e )
{
        // Transform technical errors to user-friendly messages
        $message = $this->formatValidationMessage( $e->getMessage());
        $this->redirect( 'contact', [], ['error', $message] );
    }
}

private function formatValidationMessage( string $error ): string
{
    $messages = [
        'email validation failed' => 'Please enter a valid email address',
        'length validation failed' => 'Input is too short or too long',
        'value is required' => 'This field is required'
    ];

    foreach( $messages as $pattern => $friendly )
{
        if (str_contains( $error, $pattern )) {
            return $friendly;
        }
    }

    return 'Please check your input and try again';
}

Consistent Error Handling

Use consistent error handling patterns:

trait HandlesFormErrors
{
    protected function handleFormValidation( string $dtoName,
        Request $request,
        string $redirectRoute ): Dto {
        try {
            $dto = $this->createDtoFromRequest( $dtoName, $request );
            $this->validateDtoOrFail( $dto );
            return $dto;

        } catch( \Exception $e )
{
            $this->redirect( $redirectRoute, [], ['error', $e->getMessage()]);
        }
    }
}

Testing DTOs

Test DTO validation thoroughly:

use PHPUnit\Framework\TestCase;
use Neuron\Dto\Factory;

class RegisterUserDtoTest extends TestCase
{
    public function testValidData(): void
    {
        $factory = new Factory( 'config/dtos/RegisterUser.yaml' );
        $dto = $factory->create();

        $dto->username = 'johndoe';
        $dto->email = '[email protected]';
        $dto->password = 'SecurePass123!';

        $dto->validate();

        $this->assertEmpty( $dto->getErrors());
    }

    public function testInvalidUsername(): void
    {
        $factory = new Factory( 'config/dtos/RegisterUser.yaml' );
        $dto = $factory->create();

        $dto->username = 'ab';  // Too short
        $dto->email = '[email protected]';
        $dto->password = 'SecurePass123!';

        $this->expectException( \Neuron\Core\Exceptions\Validation::class );
        $dto->validate();
    }

    public function testNestedValidation(): void
    {
        $factory = new Factory( 'config/dtos/RegisterUser.yaml' );
        $dto = $factory->create();

        $dto->username = 'johndoe';
        $dto->email = '[email protected]';
        $dto->password = 'SecurePass123!';

        // Invalid nested address
        $dto->address->zip = '123';  // Too short

        $this->expectException( \Neuron\Core\Exceptions\Validation::class );
        $dto->validate();
    }
}

Testing

Unit Testing Controllers

Test controllers with DTO validation:

use PHPUnit\Framework\TestCase;
use Neuron\Mvc\Requests\Request;

class RegistrationControllerTest extends TestCase
{
    public function testSuccessfulRegistration(): void
    {
        $controller = new RegistrationController();
        $request = new Request();

        // Mock POST data
        $_POST = [
            'username' => 'johndoe',
            'email' => '[email protected]',
            'password' => 'SecurePass123!',
            'password_confirmation' => 'SecurePass123!'
        ];

        // Process registration
        $controller->processRegistration( $request );

        // Assert redirect to success page
        $this->assertEquals( 'verify_email_sent', $controller->getRedirectRoute());
    }

    public function testValidationFailure(): void
    {
        $controller = new RegistrationController();
        $request = new Request();

        // Mock invalid POST data
        $_POST = [
            'username' => 'ab',  // Too short
            'email' => 'invalid-email',
            'password' => 'short'
        ];

        // Process registration
        $controller->processRegistration( $request );

        // Assert redirect to error page
        $this->assertEquals( 'register', $controller->getRedirectRoute());
        $this->assertNotEmpty( $controller->getFlashMessage('error' ));
    }
}

Integration Testing

Test request/DTO integration:

public function testRequestDtoIntegration(): void
{
    $request = new Request();
    $request->loadFile( 'config/requests/login.yaml' );

    $payload = [
        'username' => 'johndoe',
        'password' => 'SecurePass123!'
    ];

    $request->processPayload( $payload );

    $dto = $request->getDto();

    $this->assertEquals( 'johndoe', $dto->username );
    $this->assertEquals( 'SecurePass123!', $dto->password );
    $this->assertEmpty( $request->getErrors());
}

Mocking DTO Factory

Mock DTO factory in tests:

use PHPUnit\Framework\TestCase;

class UserServiceTest extends TestCase
{
    public function testCreateUser(): void
    {
        // Create mock DTO
        $dto = $this->createMock( \Neuron\Dto\Dto::class );
        $dto->username = 'johndoe';
        $dto->email = '[email protected]';

        // Mock factory
        $factory = $this->createMock( DtoFactoryService::class );
        $factory->method( 'create' )->willReturn( $dto );

        // Inject into service
        $service = new UserService( $factory, $this->userRepository );

        // Test service method
        $user = $service->createFromDto( $dto );

        $this->assertEquals( 'johndoe', $user->getUsername());
    }
}