Пользовательские обработчики форм
Создание собственных обработчиков для сложной бизнес-логики и нестандартных операций
Введение
Пользовательские обработчики позволяют создавать сложную логику обработки форм, которая выходит за рамки стандартных 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];
}
}
?>
Следующий шаг: Изучите загрузку файлов для работы с изображениями и документами.