← Назад к вопросам
Форма обратной связи с обработкой заявок
2.3 Middle🔥 161 комментариев
#Очереди и брокеры сообщений#Тестирование#Фреймворки
Условие
Реализовать форму обратной связи на Laravel с фиксацией заявок и возможностью их обработки менеджером.
Публичная часть
- Форма с полями: имя, email, телефон, сообщение
- Валидация всех полей
- Отправка уведомления на email менеджера
- Показ сообщения об успешной отправке
Административная часть
- Список заявок с пагинацией
- Фильтрация по статусу и дате
- Смена статуса заявки: новая, в работе, завершена
- Добавление комментария к заявке
Требования
- Защита от спама (captcha или honeypot)
- Очереди для отправки email
- Unit и Feature тесты
Технологии
Laravel, MySQL, Laravel Mail, Laravel Queue
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Решение
1. Миграции
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
// Таблица обращений
Schema::create("inquiries", function (Blueprint $table) {
$table->id();
$table->string("name", 255);
$table->string("email", 255);
$table->string("phone", 20);
$table->text("message");
$table->enum("status", ["new", "in_progress", "completed"])->default("new");
$table->ipAddress("ip_address")->nullable();
$table->timestamps();
$table->index("status");
$table->index("created_at");
$table->index("email");
});
// Таблица комментариев к обращениям
Schema::create("inquiry_comments", function (Blueprint $table) {
$table->id();
$table->foreignId("inquiry_id")->constrained("inquiries")->cascadeOnDelete();
$table->foreignId("user_id")->constrained("users");
$table->text("comment");
$table->timestamps();
$table->index("inquiry_id");
});
// Таблица для защиты от спама
Schema::create("form_submissions", function (Blueprint $table) {
$table->id();
$table->ipAddress("ip_address");
$table->timestamps();
$table->index("ip_address");
$table->index("created_at");
});
}
public function down(): void {
Schema::dropIfExists("form_submissions");
Schema::dropIfExists("inquiry_comments");
Schema::dropIfExists("inquiries");
}
};
2. Модели
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Inquiry extends Model {
protected $fillable = ["name", "email", "phone", "message", "status", "ip_address"];
public function comments(): HasMany {
return $this->hasMany(InquiryComment::class)->latest();
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InquiryComment extends Model {
protected $fillable = ["inquiry_id", "user_id", "comment"];
public function inquiry(): BelongsTo {
return $this->belongsTo(Inquiry::class);
}
public function user(): BelongsTo {
return $this->belongsTo(User::class);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class FormSubmission extends Model {
protected $fillable = ["ip_address"];
public $timestamps = true;
}
3. Mail класс
<?php
namespace App\Mail;
use App\Models\Inquiry;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class NewInquiryNotification extends Mailable {
use Queueable, SerializesModels;
public function __construct(private Inquiry $inquiry) {}
public function envelope(): Envelope {
return new Envelope(
subject: "Новая заявка: {$this->inquiry->name}",
);
}
public function content(): Content {
return new Content(
view: "emails.new_inquiry",
with: [
"inquiry" => $this->inquiry,
"adminUrl" => url("/admin/inquiries/{$this->inquiry->id}"),
],
);
}
}
4. Email шаблон
{{-- resources/views/emails/new_inquiry.blade.php --}}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<h2>Новая заявка от пользователя</h2>
<p><strong>Имя:</strong> {{ $inquiry->name }}</p>
<p><strong>Email:</strong> {{ $inquiry->email }}</p>
<p><strong>Телефон:</strong> {{ $inquiry->phone }}</p>
<p><strong>Сообщение:</strong></p>
<p>{{ $inquiry->message }}</p>
<a href="{{ $adminUrl }}">Посмотреть в админ-панели</a>
</body>
</html>
5. Service для защиты от спама
<?php
namespace App\Services;
use App\Models\FormSubmission;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class SpamProtectionService {
const MAX_SUBMISSIONS_PER_MINUTE = 3;
const MAX_SUBMISSIONS_PER_HOUR = 10;
public function checkSpam(Request $request): void {
$ipAddress = $request->ip();
// Проверка Honeypot (скрытое поле)
if ($request->filled("website")) {
throw ValidationException::withMessages([
"website" => "Заполняются боты, спасибо!"
]);
}
// Проверка частоты
$nowMinusMinute = now()->subMinute();
$nowMinusHour = now()->subHour();
$submissionsLastMinute = FormSubmission::where("ip_address", $ipAddress)
->where("created_at", ">", $nowMinusMinute)
->count();
if ($submissionsLastMinute >= self::MAX_SUBMISSIONS_PER_MINUTE) {
throw ValidationException::withMessages([
"message" => "Пожалуйста, подождите перед отправкой следующего сообщения."
]);
}
$submissionsLastHour = FormSubmission::where("ip_address", $ipAddress)
->where("created_at", ">", $nowMinusHour)
->count();
if ($submissionsLastHour >= self::MAX_SUBMISSIONS_PER_HOUR) {
throw ValidationException::withMessages([
"message" => "Вы слишком часто отправляете сообщения. Попробуйте позже."
]);
}
}
public function recordSubmission(Request $request): void {
FormSubmission::create(["ip_address" => $request->ip()]);
}
}
6. Публичный контроллер
<?php
namespace App\Http\Controllers;
use App\Mail\NewInquiryNotification;
use App\Models\Inquiry;
use App\Services\SpamProtectionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
class ContactController extends Controller {
protected $spamProtection;
public function __construct(SpamProtectionService $spamProtection) {
$this->spamProtection = $spamProtection;
}
public function show(): View {
return view("contact.form");
}
public function store(Request $request): RedirectResponse {
// Защита от спама
$this->spamProtection->checkSpam($request);
// Валидация
$validated = $request->validate([
"name" => "required|string|max:255",
"email" => "required|email|max:255",
"phone" => "required|string|max:20|regex:/^[+\d\s\-\(\)]+$/",
"message" => "required|string|min:10|max:5000",
"website" => "nullable|string", // honeypot
]);
// Создание заявки
$inquiry = Inquiry::create([
"name" => $validated["name"],
"email" => $validated["email"],
"phone" => $validated["phone"],
"message" => $validated["message"],
"ip_address" => $request->ip(),
]);
// Отправка письма в очередь
Mail::queue(new NewInquiryNotification($inquiry));
// Запись попытки отправки для защиты от спама
$this->spamProtection->recordSubmission($request);
return redirect()
->route("contact.show")
->with("success", "Спасибо! Ваша заявка отправлена. Мы свяжемся с вами в ближайшее время.");
}
}
7. Админ контроллер
<?php
namespace App\Http\Controllers\Admin;
use App\Models\Inquiry;
use App\Models\InquiryComment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
class InquiryController extends Controller {
public function index(Request $request): View {
$query = Inquiry::query();
if ($request->has("status")) {
$query->where("status", $request->get("status"));
}
if ($request->has("from_date")) {
$query->whereDate("created_at", ">", $request->get("from_date"));
}
if ($request->has("to_date")) {
$query->whereDate("created_at", "<", $request->get("to_date"));
}
$inquiries = $query->latest()->paginate(20);
return view("admin.inquiries.index", [
"inquiries" => $inquiries,
"statuses" => ["new" => "Новая", "in_progress" => "В работе", "completed" => "Завершена"],
]);
}
public function show(Inquiry $inquiry): View {
return view("admin.inquiries.show", ["inquiry" => $inquiry->load("comments.user")]);
}
public function updateStatus(Request $request, Inquiry $inquiry): RedirectResponse {
$validated = $request->validate([
"status" => "required|in:new,in_progress,completed",
]);
$inquiry->update($validated);
return back()->with("success", "Статус обновлен");
}
public function storeComment(Request $request, Inquiry $inquiry): RedirectResponse {
$validated = $request->validate([
"comment" => "required|string|max:5000",
]);
InquiryComment::create([
"inquiry_id" => $inquiry->id,
"user_id" => Auth::id(),
"comment" => $validated["comment"],
]);
return back()->with("success", "Комментарий добавлен");
}
}
8. Публичная форма (Blade)
{{-- resources/views/contact/form.blade.php --}}
@extends("layouts.app")
@section("content")
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<h1>Обратная связь</h1>
@if($errors->any())
<div class="alert alert-danger">
<ul>
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@if(session("success"))
<div class="alert alert-success">{{ session("success") }}</div>
@endif
<form method="POST" action="{{ route("contact.store") }}">
@csrf
<div class="mb-3">
<label for="name" class="form-label">Имя</label>
<input type="text" class="form-control @error("name") is-invalid @enderror" id="name" name="name" value="{{ old("name") }}" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control @error("email") is-invalid @enderror" id="email" name="email" value="{{ old("email") }}" required>
</div>
<div class="mb-3">
<label for="phone" class="form-label">Телефон</label>
<input type="tel" class="form-control @error("phone") is-invalid @enderror" id="phone" name="phone" value="{{ old("phone") }}" required>
</div>
<div class="mb-3">
<label for="message" class="form-label">Сообщение</label>
<textarea class="form-control @error("message") is-invalid @enderror" id="message" name="message" rows="5" required>{{ old("message") }}</textarea>
</div>
{{-- Honeypot поле (скрытое от пользователя) --}}
<input type="text" name="website" style="display:none;" tabindex="-1" autocomplete="off">
<button type="submit" class="btn btn-primary">Отправить</button>
</form>
</div>
</div>
</div>
@endsection
9. Админ шаблон списка
{{-- resources/views/admin/inquiries/index.blade.php --}}
@extends("layouts.admin")
@section("content")
<div class="container-fluid">
<h1>Обращения</h1>
<form method="GET" class="mb-4">
<div class="row g-3">
<div class="col-md-3">
<select name="status" class="form-select">
<option value="">Все статусы</option>
@foreach($statuses as $key => $label)
<option value="{{ $key }}" @if(request("status") == $key) selected @endif>{{ $label }}</option>
@endforeach
</select>
</div>
<div class="col-md-2">
<input type="date" name="from_date" class="form-control" value="{{ request("from_date") }}" placeholder="От">
</div>
<div class="col-md-2">
<input type="date" name="to_date" class="form-control" value="{{ request("to_date") }}" placeholder="До">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-outline-secondary w-100">Фильтр</button>
</div>
</div>
</form>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Имя</th>
<th>Email</th>
<th>Телефон</th>
<th>Статус</th>
<th>Дата</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
@forelse($inquiries as $inquiry)
<tr>
<td>{{ $inquiry->name }}</td>
<td><a href="mailto:{{ $inquiry->email }}">{{ $inquiry->email }}</a></td>
<td>{{ $inquiry->phone }}</td>
<td><span class="badge bg-warning">{{ $statuses[$inquiry->status] ?? $inquiry->status }}</span></td>
<td>{{ $inquiry->created_at->format("d.m.Y H:i") }}</td>
<td><a href="{{ route("admin.inquiries.show", $inquiry) }}" class="btn btn-sm btn-primary">Открыть</a></td>
</tr>
@empty
<tr><td colspan="6" class="text-center">Обращений нет</td></tr>
@endforelse
</tbody>
</table>
</div>
{{ $inquiries->links() }}
</div>
@endsection
10. Тесты
<?php
namespace Tests\Feature;
use App\Models\Inquiry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ContactFormTest extends TestCase {
use RefreshDatabase;
public function test_contact_form_can_be_displayed(): void {
$response = $this->get(route("contact.show"));
$response->assertStatus(200);
}
public function test_inquiry_can_be_created(): void {
$response = $this->post(route("contact.store"), [
"name" => "John Doe",
"email" => "john@example.com",
"phone" => "+7 (999) 123-45-67",
"message" => "This is a test message with more than 10 characters.",
]);
$response->assertRedirect();
$this->assertDatabaseHas("inquiries", ["email" => "john@example.com"]);
}
public function test_honeypot_protection(): void {
$response = $this->post(route("contact.store"), [
"name" => "John",
"email" => "john@example.com",
"phone" => "+1234567890",
"message" => "Test message content here",
"website" => "http://spam.com", // honeypot заполнено
]);
$response->assertSessionHasErrors();
}
public function test_spam_protection_rate_limit(): void {
for ($i = 0; $i < 3; $i++) {
$this->post(route("contact.store"), [
"name" => "Test",
"email" => "test@example.com",
"phone" => "+1234567890",
"message" => "Test message here",
]);
}
$response = $this->post(route("contact.store"), [
"name" => "Test",
"email" => "test2@example.com",
"phone" => "+1234567890",
"message" => "Test message here",
]);
$response->assertSessionHasErrors();
}
}
Это полное решение с формой обратной связи, защитой от спама, очередями email и админ-панелью для обработки.