Пользовательские обработчики форм

Создание собственных обработчиков для сложной бизнес-логики и нестандартных операций

Введение

Пользовательские обработчики позволяют создавать сложную логику обработки форм, которая выходит за рамки стандартных CRUD операций. Они написаны на PHP и размещаются в директории /src/handlers/.

Структура обработчика

Базовый шаблон

<?php
// /src/handlers/example-handler.php

return function($request, $response, $context) {
    // $request - объект HTTP запроса
    // $response - объект HTTP ответа  
    // $context - контекст приложения (БД, переводчик и т.д.)
    
    try {
        // Получение данных из формы
        $name = $request->input('name');
        $email = $request->input('email');
        
        // Валидация
        $errors = [];
        if (empty($name)) {
            $errors['name'] = 'Имя обязательно для заполнения';
        }
        
        if (!empty($errors)) {
            return [
                'success' => false,
                'errors' => $errors
            ];
        }
        
        // Бизнес-логика
        // ...
        
        return [
            'success' => true,
            'message' => 'Операция выполнена успешно',
            'redirect' => '/success-page'
        ];
        
    } catch (Exception $e) {
        error_log('Handler error: ' . $e->getMessage());
        
        return [
            'success' => false,
            'error' => 'Произошла ошибка при обработке запроса'
        ];
    }
};
?>

Использование обработчиков в формах

Подключение обработчика

<form method="POST" action="/">
    {{ csrf() }}
    
    <!-- Указываем имя обработчика -->
    <input type="hidden" name="__handler" value="example-handler">
    
    <div class="form-group">
        <label>Имя</label>
        <input type="text" name="name" value="{{ old('name') }}" required>
        {% if errors.name %}
            <div class="error">{{ errors.name }}</div>
        {% endif %}
    </div>
    
    <button type="submit">Отправить</button>
</form>

Работа с данными запроса

Получение данных формы

<?php
// /src/handlers/user-registration.php

return function($request, $response, $context) {
    // Простые поля
    $login = $request->input('login');
    $email = $request->input('email');
    $password = $request->input('password');
    
    // Поля со значениями по умолчанию
    $role = $request->input('role', 'user');
    $is_active = $request->input('is_active', 0);
    
    // Массивы
    $interests = $request->input('interests', []);
    $permissions = $request->input('permissions', []);
    
    // Булевые значения
    $newsletter = $request->boolean('newsletter');
    $terms_accepted = $request->boolean('terms_accepted');
    
    // Файлы
    $avatar = $request->file('avatar');
    $documents = $request->file('documents'); // Множественная загрузка
    
    // Все данные сразу
    $allInput = $request->all();
    
    // Только определенные поля
    $userData = $request->only(['login', 'email', 'first_name', 'last_name']);
    
    // Исключить определенные поля
    $safeData = $request->except(['password', 'password_confirmation']);
    
    // Информация о запросе
    $userIP = $request->ip();
    $userAgent = $request->userAgent();
    $isAjax = $request->isAjax();
    
    // ...
};
?>

Валидация данных

Ручная валидация

<?php
// /src/handlers/contact-form.php

return function($request, $response, $context) {
    $name = trim($request->input('name'));
    $email = trim($request->input('email'));
    $phone = trim($request->input('phone'));
    $message = trim($request->input('message'));
    
    $errors = [];
    
    // Проверка обязательных полей
    if (empty($name)) {
        $errors['name'] = 'Имя обязательно для заполнения';
    } elseif (strlen($name) < 2) {
        $errors['name'] = 'Имя должно содержать минимум 2 символа';
    } elseif (strlen($name) > 50) {
        $errors['name'] = 'Имя не должно превышать 50 символов';
    }
    
    // Проверка email
    if (empty($email)) {
        $errors['email'] = 'Email обязателен для заполнения';
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors['email'] = 'Введите корректный email адрес';
    }
    
    // Проверка телефона (необязательное поле)
    if (!empty($phone) && !preg_match('/^[0-9{10,11}]$/', $phone)) {
        $errors['phone'] = 'Введите корректный номер телефона';
    }
    
    // Проверка сообщения
    if (empty($message)) {
        $errors['message'] = 'Сообщение обязательно для заполнения';
    } elseif (strlen($message) < 10) {
        $errors['message'] = 'Сообщение должно содержать минимум 10 символов';
    } elseif (strlen($message) > 1000) {
        $errors['message'] = 'Сообщение не должно превышать 1000 символов';
    }
    
    // Если есть ошибки, возвращаем их
    if (!empty($errors)) {
        return [
            'success' => false,
            'errors' => $errors,
            'old_input' => $request->all()
        ];
    }
    
    // Продолжаем обработку...
};
?>

Продвинутая валидация

<?php
// /src/handlers/user-profile-update.php

return function($request, $response, $context) {
    $db = $context->db('1752665380840');
    $translator = $context->translator;
    $userId = $_SESSION['user_id'] ?? null;
    
    if (!$userId) {
        return [
            'success' => false,
            'error' => $translator->translate('auth.required')
        ];
    }
    
    $data = $request->only([
        'first_name', 'last_name', 'email', 'phone', 
        'birth_date', 'bio', 'website', 'location'
    ]);
    
    $errors = [];
    
    // Проверка уникальности email
    if (!empty($data['email'])) {
        $existingUser = $db->getItem('users', [
            'email' => $data['email'],
            'id' => '!=' . $userId
        ]);
        
        if ($existingUser) {
            $errors['email'] = $translator->translate('validation.email_exists');
        }
    }
    
    // Проверка даты рождения
    if (!empty($data['birth_date'])) {
        $birthDate = strtotime($data['birth_date']);
        if (!$birthDate) {
            $errors['birth_date'] = $translator->translate('validation.invalid_date');
        } else {
            $age = floor((time() - $birthDate) / (365.25 * 24 * 3600));
            if ($age < 13) {
                $errors['birth_date'] = $translator->translate('validation.min_age', ['age' => 13]);
            } elseif ($age > 120) {
                $errors['birth_date'] = $translator->translate('validation.max_age', ['age' => 120]);
            }
        }
    }
    
    // Проверка website
    if (!empty($data['website']) && !filter_var($data['website'], FILTER_VALIDATE_URL)) {
        $errors['website'] = $translator->translate('validation.invalid_url');
    }
    
    // Проверка длины биографии
    if (!empty($data['bio']) && strlen($data['bio']) > 500) {
        $errors['bio'] = $translator->translate('validation.max_length', ['field' => 'bio', 'max' => 500]);
    }
    
    if (!empty($errors)) {
        return [
            'success' => false,
            'errors' => $errors
        ];
    }
    
    // Обновление профиля
    $data['updated_at'] = time();
    
    $updated = $db->editItem('users', ['id' => $userId], $data);
    
    if ($updated) {
        return [
            'success' => true,
            'message' => $translator->translate('profile.updated_successfully'),
            'redirect' => '/profile'
        ];
    } else {
        return [
            'success' => false,
            'error' => $translator->translate('profile.update_failed')
        ];
    }
};
?>

Работа с базой данных

Сложные операции с БД

<?php
// /src/handlers/transfer-money.php

return function($request, $response, $context) {
    $db = $context->db('1752665380840');
    $translator = $context->translator;
    
    $fromUserId = $_SESSION['user_id'] ?? null;
    $toUserLogin = $request->input('to_user');
    $amount = (float)$request->input('amount');
    $comment = $request->input('comment', '');
    
    // Валидация
    if (!$fromUserId) {
        return ['success' => false, 'error' => $translator->translate('auth.required')];
    }
    
    if ($amount <= 0) {
        return ['success' => false, 'error' => $translator->translate('transfer.invalid_amount')];
    }
    
    if (empty($toUserLogin)) {
        return ['success' => false, 'error' => $translator->translate('transfer.recipient_required')];
    }
    
    // Начинаем транзакцию
    $db->beginTransaction();
    
    try {
        // Получаем отправителя
        $sender = $db->getItem('users', ['id' => $fromUserId]);
        if (!$sender) {
            throw new Exception($translator->translate('user.not_found'));
        }
        
        // Получаем получателя
        $receiver = $db->getItem('users', ['login' => $toUserLogin]);
        if (!$receiver) {
            throw new Exception($translator->translate('transfer.recipient_not_found'));
        }
        
        if ($receiver['id'] == $fromUserId) {
            throw new Exception($translator->translate('transfer.self_transfer'));
        }
        
        // Проверяем баланс
        if ($sender['balance'] < $amount) {
            throw new Exception($translator->translate('transfer.insufficient_funds'));
        }
        
        // Проверяем лимиты
        $dailyTransferred = $db->query('
            SELECT COALESCE(SUM(amount), 0) as total
            FROM transfers 
            WHERE from_user_id = ? AND created_at >= ?
        ', [$fromUserId, strtotime('today')])[0]['total'];
        
        $dailyLimit = $sender['daily_transfer_limit'] ?? 10000;
        
        if ($dailyTransferred + $amount > $dailyLimit) {
            throw new Exception($translator->translate('transfer.daily_limit_exceeded'));
        }
        
        // Рассчитываем комиссию
        $commission = $amount > 1000 ? $amount * 0.01 : 0;
        $totalDebit = $amount + $commission;
        
        if ($sender['balance'] < $totalDebit) {
            throw new Exception($translator->translate('transfer.insufficient_funds_with_commission'));
        }
        
        // Выполняем перевод
        $db->editItem('users', ['id' => $fromUserId], [
            'balance' => $sender['balance'] - $totalDebit
        ]);
        
        $db->editItem('users', ['id' => $receiver['id']], [
            'balance' => $receiver['balance'] + $amount
        ]);
        
        // Записываем операцию
        $transferId = $db->addItem('transfers', [
            'from_user_id' => $fromUserId,
            'to_user_id' => $receiver['id'],
            'amount' => $amount,
            'commission' => $commission,
            'comment' => $comment,
            'status' => 'completed',
            'created_at' => time(),
            'ip_address' => $request->ip()
        ]);
        
        // Записываем в историю балансов
        $db->addItem('balance_history', [
            'user_id' => $fromUserId,
            'type' => 'transfer_out',
            'amount' => -$totalDebit,
            'balance_before' => $sender['balance'],
            'balance_after' => $sender['balance'] - $totalDebit,
            'description' => "Перевод пользователю {$receiver['login']}",
            'reference_id' => $transferId,
            'created_at' => time()
        ]);
        
        $db->addItem('balance_history', [
            'user_id' => $receiver['id'],
            'type' => 'transfer_in',
            'amount' => $amount,
            'balance_before' => $receiver['balance'],
            'balance_after' => $receiver['balance'] + $amount,
            'description' => "Перевод от пользователя {$sender['login']}",
            'reference_id' => $transferId,
            'created_at' => time()
        ]);
        
        // Отправляем уведомление получателю
        $db->addItem('notifications', [
            'user_id' => $receiver['id'],
            'type' => 'money_received',
            'title' => $translator->translate('notification.money_received_title'),
            'message' => $translator->translate('notification.money_received_message', [
                'amount' => number_format($amount, 2),
                'sender' => $sender['login']
            ]),
            'data' => json_encode([
                'transfer_id' => $transferId,
                'amount' => $amount,
                'sender' => $sender['login']
            ]),
            'created_at' => time()
        ]);
        
        $db->commit();
        
        return [
            'success' => true,
            'message' => $translator->translate('transfer.success', [
                'amount' => number_format($amount, 2),
                'recipient' => $receiver['login']
            ]),
            'data' => [
                'transfer_id' => $transferId,
                'new_balance' => $sender['balance'] - $totalDebit,
                'commission' => $commission
            ]
        ];
        
    } catch (Exception $e) {
        $db->rollback();
        
        error_log("Transfer error: " . $e->getMessage() . " User: {$fromUserId}");
        
        return [
            'success' => false,
            'error' => $e->getMessage()
        ];
    }
};
?>

AJAX обработка

AJAX форма в шаблоне

<form method="POST" action="/" data-ajax="true" class="ajax-form">
    {{ csrf() }}
    
    <input type="hidden" name="__handler" value="update-settings">
    
    <div class="form-group">
        <label>Имя пользователя</label>
        <input type="text" name="display_name" value="{{ user.display_name }}">
    </div>
    
    <div class="form-group">
        <label>
            <input type="checkbox" name="email_notifications" value="1" 
                   {{ user.email_notifications ? 'checked' : '' }}>
            Email уведомления
        </label>
    </div>
    
    <div class="form-group">
        <label>Часовой пояс</label>
        <select name="timezone">
            {% foreach timezones as tz %}
                <option value="{{ tz }}" {{ tz == user.timezone ? 'selected' : '' }}>
                    {{ tz }}
                </option>
            {% endforeach %}
        </select>
    </div>
    
    <button type="submit" class="btn btn-primary">
        <span class="btn-text">Сохранить</span>
        <span class="btn-loading" style="display: none;">
            <i class="fas fa-spinner fa-spin"></i> Сохранение...
        </span>
    </button>
</form>

<script>
document.addEventListener('DOMContentLoaded', function() {
    document.querySelectorAll('form[data-ajax="true"]').forEach(form => {
        form.addEventListener('submit', async function(e) {
            e.preventDefault();
            
            const formData = new FormData(form);
            const submitBtn = form.querySelector('button[type="submit"]');
            const btnText = submitBtn.querySelector('.btn-text');
            const btnLoading = submitBtn.querySelector('.btn-loading');
            
            // Показываем загрузку
            submitBtn.disabled = true;
            btnText.style.display = 'none';
            btnLoading.style.display = 'inline';
            
            try {
                const response = await fetch('/', {
                    method: 'POST',
                    body: formData,
                    headers: {
                        'X-Requested-With': 'XMLHttpRequest'
                    }
                });
                
                const result = await response.json();
                
                if (result.success) {
                    showNotification(result.message || 'Настройки сохранены!', 'success');
                    
                    if (result.redirect) {
                        setTimeout(() => {
                            window.location.href = result.redirect;
                        }, 1000);
                    }
                } else {
                    if (result.errors) {
                        showFormErrors(form, result.errors);
                    } else {
                        showNotification(result.error || 'Произошла ошибка', 'error');
                    }
                }
                
            } catch (error) {
                showNotification('Ошибка соединения с сервером', 'error');
            } finally {
                // Возвращаем кнопку в исходное состояние
                submitBtn.disabled = false;
                btnText.style.display = 'inline';
                btnLoading.style.display = 'none';
            }
        });
    });
});

function showNotification(message, type) {
    const notification = document.createElement('div');
    notification.className = `notification notification-{$type}`;
    notification.innerHTML = `
        <span>{$message}</span>
        <button onclick="this.parentElement.remove()">×</button>
    `;
    
    document.body.appendChild(notification);
    
    setTimeout(() => {
        notification.remove();
    }, 5000);
}

function showFormErrors(form, errors) {
    // Удаляем старые ошибки
    form.querySelectorAll('.field-error').forEach(error => error.remove());
    
    Object.keys(errors).forEach(fieldName => {
        const field = form.querySelector(`[name="{$fieldName}"]`);
        if (field) {
            const error = document.createElement('div');
            error.className = 'field-error';
            error.textContent = errors[fieldName];
            field.parentNode.appendChild(error);
        }
    });
}
</script>

AJAX обработчик

<?php
// /src/handlers/update-settings.php

return function($request, $response, $context) {
    $userId = $_SESSION['user_id'] ?? null;
    
    if (!$userId) {
        return [
            'success' => false,
            'error' => 'Необходима авторизация'
        ];
    }
    
    $db = $context->db('1752665380840');
    
    // Получаем данные
    $displayName = trim($request->input('display_name'));
    $emailNotifications = $request->boolean('email_notifications');
    $timezone = $request->input('timezone');
    
    // Валидация
    $errors = [];
    
    if (empty($displayName)) {
        $errors['display_name'] = 'Имя пользователя обязательно';
    } elseif (strlen($displayName) < 2) {
        $errors['display_name'] = 'Минимум 2 символа';
    } elseif (strlen($displayName) > 50) {
        $errors['display_name'] = 'Максимум 50 символов';
    }
    
    if (!in_array($timezone, timezone_identifiers_list())) {
        $errors['timezone'] = 'Неверный часовой пояс';
    }
    
    if (!empty($errors)) {
        return [
            'success' => false,
            'errors' => $errors
        ];
    }
    
    // Обновляем настройки
    $updated = $db->editItem('users', ['id' => $userId], [
        'display_name' => $displayName,
        'email_notifications' => $emailNotifications ? 1 : 0,
        'timezone' => $timezone,
        'updated_at' => time()
    ]);
    
    if ($updated) {
        // Для AJAX запросов возвращаем JSON
        if ($request->isAjax()) {
            return [
                'success' => true,
                'message' => 'Настройки успешно обновлены'
            ];
        } else {
            // Для обычных форм - перенаправление с flash сообщением
            $_SESSION['flash_success'] = 'Настройки успешно обновлены';
            return [
                'success' => true,
                'redirect' => '/settings'
            ];
        }
    } else {
        return [
            'success' => false,
            'error' => 'Не удалось сохранить настройки'
        ];
    }
};
?>

Интеграция с внешними API

Отправка email через API

<?php
// /src/handlers/send-newsletter.php

return function($request, $response, $context) {
    $db = $context->db('1752665380840');
    $translator = $context->translator;
    
    $subject = $request->input('subject');
    $message = $request->input('message');
    $recipientGroups = $request->input('recipient_groups', []);
    
    // Валидация
    $errors = [];
    
    if (empty($subject)) {
        $errors['subject'] = $translator->translate('validation.required', ['field' => 'Subject']);
    }
    
    if (empty($message)) {
        $errors['message'] = $translator->translate('validation.required', ['field' => 'Message']);
    }
    
    if (empty($recipientGroups)) {
        $errors['recipient_groups'] = 'Выберите группы получателей';
    }
    
    if (!empty($errors)) {
        return ['success' => false, 'errors' => $errors];
    }
    
    try {
        // Получаем получателей
        $conditions = [
            'email_notifications' => 1,
            'email' => '!=',
            'ban' => 0
        ];
        
        if (!in_array('all', $recipientGroups)) {
            $conditions['role'] = $recipientGroups;
        }
        
        $recipients = $db->getItems('users', $conditions, ['id', 'email', 'first_name', 'last_name']);
        
        if (empty($recipients)) {
            return [
                'success' => false,
                'error' => 'Не найдено получателей с указанными критериями'
            ];
        }
        
        // Создаем рассылку
        $newsletterId = $db->addItem('newsletters', [
            'subject' => $subject,
            'message' => $message,
            'recipient_groups' => json_encode($recipientGroups),
            'total_recipients' => count($recipients),
            'status' => 'pending',
            'created_by' => $_SESSION['user_id'],
            'created_at' => time()
        ]);
        
        // Добавляем получателей в очередь
        $emailQueue = [];
        foreach ($recipients as $recipient) {
            $personalizedMessage = str_replace(
                ['{name}', '{first_name}'],
                [$recipient['first_name'] . ' ' . $recipient['last_name'], $recipient['first_name']],
                $message
            );
            
            $emailQueue[] = [
                'newsletter_id' => $newsletterId,
                'recipient_id' => $recipient['id'],
                'email' => $recipient['email'],
                'subject' => $subject,
                'message' => $personalizedMessage,
                'status' => 'queued',
                'created_at' => time()
            ];
        }
        
        // Добавляем все письма в очередь пакетно
        foreach (array_chunk($emailQueue, 100) as $batch) {
            $db->query('INSERT INTO email_queue (newsletter_id, recipient_id, email, subject, message, status, created_at) VALUES ' .
                      implode(',', array_fill(0, count($batch), '(?, ?, ?, ?, ?, ?, ?)')) ,
                      array_merge(...array_map('array_values', $batch)));
        }
        
        // Запускаем отправку (в реальном проекте лучше использовать очередь)
        $this->processEmailQueue($db, $newsletterId);
        
        return [
            'success' => true,
            'message' => "Рассылка запущена для {count($recipients)} получателей",
            'redirect' => "/admin/newsletters/{$newsletterId}"
        ];
        
    } catch (Exception $e) {
        error_log("Newsletter error: " . $e->getMessage());
        
        return [
            'success' => false,
            'error' => 'Произошла ошибка при создании рассылки'
        ];
    }
};

function processEmailQueue($db, $newsletterId) {
    $emails = $db->getItems('email_queue', [
        'newsletter_id' => $newsletterId,
        'status' => 'queued'
    ], ['*'], 'created_at', 'ASC', null, 50); // Ограничиваем 50 писем за раз
    
    $apiKey = config('mail.api_key');
    $apiUrl = config('mail.api_url');
    
    $sent = 0;
    $failed = 0;
    
    foreach ($emails as $email) {
        try {
            // Отправляем через API (например, SendGrid, Mailgun и т.д.)
            $response = $this->sendEmail($apiUrl, $apiKey, [
                'to' => $email['email'],
                'subject' => $email['subject'],
                'html' => $email['message']
            ]);
            
            if ($response['success']) {
                $db->editItem('email_queue', ['id' => $email['id']], [
                    'status' => 'sent',
                    'sent_at' => time(),
                    'api_response' => json_encode($response)
                ]);
                $sent++;
            } else {
                $db->editItem('email_queue', ['id' => $email['id']], [
                    'status' => 'failed',
                    'error_message' => $response['error'] ?? 'Unknown error',
                    'failed_at' => time()
                ]);
                $failed++;
            }
            
        } catch (Exception $e) {
            $db->editItem('email_queue', ['id' => $email['id']], [
                'status' => 'failed',
                'error_message' => $e->getMessage(),
                'failed_at' => time()
            ]);
            $failed++;
        }
        
        // Пауза между отправками (ограничение API)
        usleep(100000); // 0.1 секунды
    }
    
    // Обновляем статистику рассылки
    $db->editItem('newsletters', ['id' => $newsletterId], [
        'sent_count' => $db->query('SELECT COUNT(*) as count FROM email_queue WHERE newsletter_id = ? AND status = "sent"', [$newsletterId])[0]['count'],
        'failed_count' => $db->query('SELECT COUNT(*) as count FROM email_queue WHERE newsletter_id = ? AND status = "failed"', [$newsletterId])[0]['count'],
        'status' => 'completed',
        'completed_at' => time()
    ]);
}

function sendEmail($apiUrl, $apiKey, $data) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $apiUrl);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Content-Type: application/json',
        'Authorization: Bearer ' . $apiKey
    ]);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    
    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    if ($httpCode === 200) {
        return ['success' => true, 'response' => json_decode($response, true)];
    } else {
        return ['success' => false, 'error' => $response, 'http_code' => $httpCode];
    }
}
?>
Следующий шаг: Изучите загрузку файлов для работы с изображениями и документами.