DALT.PHP
Framework Deep Dive4. Session & State

Validation Errors

How validation exceptions flow through the framework to show form errors

The Validation Flow

When a form has invalid data, DALT.PHP uses a special exception to handle it gracefully. Let's trace the complete flow from submission to error display.


The ValidationException Class

class ValidationException extends \Exception
{
    public $errors;
    public $old;
    
    public static function throw($errors, $old)
    {
        $instance = new static;
        $instance->errors = $errors;
        $instance->old = $old;
        throw $instance;
    }
}

Why a Custom Exception?

Regular exceptions:

throw new \Exception("Validation failed");
// Just a message, no structured data

ValidationException:

ValidationException::throw(
    ['email' => 'Invalid email'],  // Errors
    ['email' => 'test@']            // Old input
);
// Carries both errors and old input

The Static throw() Method

public static function throw($errors, $old)
{
    $instance = new static;
    $instance->errors = $errors;
    $instance->old = $old;
    throw $instance;
}

Why static?

Convenience. Instead of:

$exception = new ValidationException();
$exception->errors = $errors;
$exception->old = $old;
throw $exception;

You can:

ValidationException::throw($errors, $old);

What new static means:

  • Creates instance of the current class
  • Works with inheritance
  • Same as new self in this case

In the Controller

Example: Registration

// app/Http/controllers/registration/store.php

$email = $_POST['email'];
$password = $_POST['password'];

$errors = [];

// Validate email
if (!Validator::email($email)) {
    $errors['email'] = 'Please provide a valid email address';
}

// Validate password
if (!Validator::string($password, 7, 255)) {
    $errors['password'] = 'Password must be at least 7 characters';
}

// If errors exist, throw exception
if (!empty($errors)) {
    ValidationException::throw($errors, $_POST);
}

// If we reach here, validation passed
// Create the user...
$db = App::resolve(Database::class);

$hashedPassword = password_hash($password, PASSWORD_BCRYPT);

$db->query('INSERT INTO users (email, password) VALUES (:email, :password)', [
    'email' => $email,
    'password' => $hashedPassword
]);

// Log them in
$auth = new Authenticator();
$auth->login(['email' => $email]);

redirect('/');

Breaking Down the Validation

1. Get input

$email = $_POST['email'];
$password = $_POST['password'];

2. Initialize errors array

$errors = [];

3. Validate each field

if (!Validator::email($email)) {
    $errors['email'] = 'Please provide a valid email address';
}

The Validator class has static methods:

class Validator
{
    public static function string($value, $min = 1, $max = INF)
    {
        $value = trim($value);
        return strlen($value) >= $min && strlen($value) <= $max;
    }
    
    public static function email($value)
    {
        return filter_var($value, FILTER_VALIDATE_EMAIL);
    }
}

4. Throw if errors exist

if (!empty($errors)) {
    ValidationException::throw($errors, $_POST);
}

Why pass $_POST as old input?

  • So the form can be pre-filled
  • User doesn't have to retype everything
  • Better UX

5. Continue if valid

If no exception is thrown, the code continues and creates the user.


In the Entry Point

Remember from Part 1.4, public/index.php catches this exception:

try {
    $router->route($uri, $method, $request);
} catch (ValidationException $exception) {
    Session::flash('errors', $exception->errors);
    Session::flash('old', $exception->old);
    redirect($router->previousUrl());
} catch (\Throwable $e) {
    app_log(get_class($e) . ': ' . $e->getMessage());
    throw $e;
}

The Catch Block

1. Flash the errors

Session::flash('errors', $exception->errors);

Stores:

$_SESSION['_flash']['errors'] = [
    'email' => 'Please provide a valid email address',
    'password' => 'Password must be at least 7 characters'
];

2. Flash the old input

Session::flash('old', $exception->old);

Stores:

$_SESSION['_flash']['old'] = [
    'email' => 'test@',
    'password' => 'short'
];

3. Redirect back

redirect($router->previousUrl());

What previousUrl() does:

public function previousUrl()
{
    return $this->request?->server('HTTP_REFERER') 
        ?? $_SERVER['HTTP_REFERER'] 
        ?? '/';
}

Reads the Referer header (the page you came from).

Example:

User is on: GET /register
Submits to:  POST /register
Referer:     /register
Redirects:   /register

In the View

Displaying Errors

// resources/views/registration/create.view.php

<?php
$errors = Session::get('errors', []);
?>

<form method="POST" action="/register">
    <?= csrf_field() ?>
    
    <div>
        <label>Email</label>
        <input 
            type="email" 
            name="email" 
            value="<?= htmlspecialchars(old('email')) ?>"
            required
        >
        <?php if (isset($errors['email'])): ?>
            <p class="error"><?= htmlspecialchars($errors['email']) ?></p>
        <?php endif; ?>
    </div>
    
    <div>
        <label>Password</label>
        <input 
            type="password" 
            name="password" 
            value="<?= htmlspecialchars(old('password')) ?>"
            required
        >
        <?php if (isset($errors['password'])): ?>
            <p class="error"><?= htmlspecialchars($errors['password']) ?></p>
        <?php endif; ?>
    </div>
    
    <button type="submit">Register</button>
</form>

Breaking Down the View

1. Get errors from flash

$errors = Session::get('errors', []);

Returns the errors array or empty array if none.

2. Pre-fill input with old value

value="<?= htmlspecialchars(old('email')) ?>"

The old() helper:

function old($key, $default = '')
{
    return Core\Session::get('old')[$key] ?? $default;
}

3. Show error if exists

<?php if (isset($errors['email'])): ?>
    <p class="error"><?= htmlspecialchars($errors['email']) ?></p>
<?php endif; ?>

Why htmlspecialchars()?

Security. Prevents XSS attacks:

// Malicious input
$email = '<script>alert("XSS")</script>';

// Without htmlspecialchars
echo $email;  // Executes script!

// With htmlspecialchars
echo htmlspecialchars($email);  
// Outputs: &lt;script&gt;alert("XSS")&lt;/script&gt;
// Browser shows it as text, doesn't execute

The Complete Flow Diagram

1. User fills form

2. POST /register

3. Controller validates

4. Validation fails

5. ValidationException::throw($errors, $old)

6. Exception bubbles up

7. Caught in public/index.php

8. Session::flash('errors', $errors)
   Session::flash('old', $old)

9. redirect($previousUrl)

10. GET /register

11. View reads flash data

12. Errors displayed
    Old input pre-filled

13. User fixes and resubmits

14. Validation passes

15. User created

16. Redirect to dashboard

Multiple Field Validation

Building the Errors Array

$errors = [];

// Email validation
if (!$_POST['email']) {
    $errors['email'] = 'Email is required';
} elseif (!Validator::email($_POST['email'])) {
    $errors['email'] = 'Email must be valid';
}

// Password validation
if (!$_POST['password']) {
    $errors['password'] = 'Password is required';
} elseif (!Validator::string($_POST['password'], 7, 255)) {
    $errors['password'] = 'Password must be at least 7 characters';
}

// Password confirmation
if ($_POST['password'] !== $_POST['password_confirmation']) {
    $errors['password_confirmation'] = 'Passwords must match';
}

// Terms acceptance
if (!isset($_POST['terms'])) {
    $errors['terms'] = 'You must accept the terms';
}

if (!empty($errors)) {
    ValidationException::throw($errors, $_POST);
}

Result:

$errors = [
    'email' => 'Email must be valid',
    'password' => 'Password must be at least 7 characters',
    'password_confirmation' => 'Passwords must match',
    'terms' => 'You must accept the terms'
];

Error Display Patterns

Inline Errors (Next to Field)

<input name="email" value="<?= old('email') ?>">
<?php if (isset($errors['email'])): ?>
    <span class="error"><?= $errors['email'] ?></span>
<?php endif; ?>

Error Summary (Top of Form)

<?php if (!empty($errors)): ?>
    <div class="error-summary">
        <h3>Please fix the following errors:</h3>
        <ul>
            <?php foreach ($errors as $error): ?>
                <li><?= htmlspecialchars($error) ?></li>
            <?php endforeach; ?>
        </ul>
    </div>
<?php endif; ?>

Field Highlighting

<input 
    name="email" 
    value="<?= old('email') ?>"
    class="<?= isset($errors['email']) ? 'input-error' : '' ?>"
>

CSS:

.input-error {
    border-color: red;
    background-color: #fee;
}

Why This Pattern Works

1. Separation of Concerns

  • Controller: Validates and throws
  • Entry point: Catches and flashes
  • View: Displays

Each layer has one job.

2. Consistent Error Handling

All validation errors flow through the same path:

  • Always flashed
  • Always redirected back
  • Always displayed the same way

3. Good UX

  • User sees exactly what's wrong
  • Form is pre-filled (no retyping)
  • Errors disappear after fixing

4. Security

  • htmlspecialchars() prevents XSS
  • Errors are temporary (flash)
  • No sensitive data in URLs

Key Takeaways

  1. ValidationException carries data - Errors and old input
  2. Thrown in controllers - When validation fails
  3. Caught in entry point - Flashed and redirected
  4. Displayed in views - With old input pre-filled
  5. Automatic cleanup - Flash data deleted after display

What's Good Here

✅ Clean separation of concerns
✅ Consistent error handling
✅ Great user experience
✅ Secure (XSS prevention)
✅ Simple enough to understand and extend

Design Note

Validation rule objects and automatic validation are intentionally omitted. DALT keeps validation explicit - you write the checks yourself and see exactly what's being validated. This teaches you what validation frameworks like Laravel's Validator are doing behind the scenes.


Next, explore authentication and how login/logout works.

This would require a more sophisticated validation system.


Part 4 Complete!

You now understand sessions and flash data:

  • How the Session class wraps $_SESSION
  • How flash data works for temporary messages
  • How validation errors flow through the framework
  • How forms display errors and pre-fill input

On this page