10-04-2026

This commit is contained in:
Kevin Adametz 2026-04-10 17:18:17 +02:00
parent 4d6b4930b2
commit 4bb89aad8c
836 changed files with 52961 additions and 5950 deletions

View file

@ -0,0 +1,303 @@
# Acme Contact Form
Ein wiederverwendbares, spam-geschütztes Kontaktformular-Package für Laravel mit Livewire. Bietet Honeypot, zeitbasierte Prüfung, Inhaltsanalyse, Rate Limiting und mehr ohne externe Dienste, DSGVO-konform.
## Features
- **Spam-Schutz**: Honeypot, Zeitprüfung, Inhaltsanalyse, Wegwerf-E-Mails, Rate Limiting, IP-Blacklist, Bot-User-Agent-Erkennung
- **Konfigurierbare Felder**: Presets (simple, full) oder eigene Felddefinitionen
- **Livewire**: Reaktives Formular ohne Page-Reload
- **Flexibel**: Einfache Formulare bis komplexe Multi-Feld-Formulare
- **DSGVO-konform**: Keine externen Services, alles in-house
## Voraussetzungen
- PHP 8.2+
- Laravel 10+ / 11+ / 12+
- Livewire 3+
## Installation
### 1. Package installieren
```bash
composer require acme/contact-form
```
Für lokale Entwicklung (Path-Repository):
```json
{
"repositories": [
{
"type": "path",
"url": "package/acme/contact-form"
}
],
"require": {
"acme/contact-form": "@dev"
}
}
```
```bash
composer update acme/contact-form
```
### 2. Konfiguration
```bash
php artisan vendor:publish --tag=contact-form-config
```
In `.env`:
```env
CONTACT_FORM_RECIPIENT=kontakt@ihre-domain.de
CONTACT_FORM_MAX_ATTEMPTS=3
CONTACT_FORM_DECAY_MINUTES=15
CONTACT_FORM_MIN_FILL_TIME=3
```
### 3. Übersetzungen (optional)
```bash
php artisan vendor:publish --tag=contact-form-lang
```
## Verwendung
### Einfaches Formular (Preset: simple)
Name, E-Mail, Nachricht, Datenschutz-Checkbox:
```blade
<livewire:contact-form />
```
Oder mit explizitem Preset:
```blade
<livewire:contact-form preset="simple" subject="Kontaktanfrage" />
```
### Vollständiges Formular (Preset: full)
Vorname, Nachname, E-Mail, Telefon, Unternehmen, Nachricht, Datenschutz:
```blade
<livewire:contact-form preset="full" subject="Neue Kontaktanfrage" />
```
### Eigenes Formular mit benutzerdefinierten Feldern
Sie können beliebige Felder definieren. Jedes Feld benötigt:
- `type`: `text`, `email`, `tel`, `textarea`, `select`, `checkbox`, `honeypot`
- `rules`: Laravel-Validierungsregeln (Array)
- `label`: Anzeigename (optional)
- `placeholder`: Platzhalter (optional)
- `required`: true/false (optional, wird aus rules abgeleitet)
- `name`: Formularfeld-Name (optional, Standard: Array-Key)
- `options`: Für `select` Array [value => label] (optional)
**Beispiel: Minimales Formular (nur E-Mail + Nachricht)**
```blade
<livewire:contact-form
:fields="[
'email' => [
'type' => 'email',
'rules' => ['required', 'email:rfc,dns', 'max:150'],
'label' => 'Ihre E-Mail',
'placeholder' => 'name@beispiel.de',
],
'message' => [
'type' => 'textarea',
'rules' => ['required', 'string', 'max:2000'],
'label' => 'Nachricht',
],
'privacy' => [
'type' => 'checkbox',
'rules' => ['accepted'],
'label' => 'Ich stimme der Datenschutzerklärung zu.',
],
'honeypot' => [
'type' => 'honeypot',
'name' => 'website',
'rules' => ['nullable', 'string', 'max:0'],
],
]"
subject="Feedback"
/>
```
**Beispiel: Formular mit Select-Feld**
```blade
<livewire:contact-form
:fields="[
'name' => [
'type' => 'text',
'rules' => ['required', 'string', 'max:120'],
'label' => 'Name',
],
'email' => [
'type' => 'email',
'rules' => ['required', 'email:rfc,dns'],
'label' => 'E-Mail',
],
'topic' => [
'type' => 'select',
'rules' => ['required', 'string', 'in:general,support,sales'],
'label' => 'Betreff',
'placeholder' => 'Bitte wählen...',
'options' => [
'general' => 'Allgemeine Anfrage',
'support' => 'Technischer Support',
'sales' => 'Verkauf',
],
],
'message' => [
'type' => 'textarea',
'rules' => ['required', 'string', 'max:2000'],
'label' => 'Nachricht',
],
'privacy' => [
'type' => 'checkbox',
'rules' => ['accepted'],
'label' => 'Datenschutz akzeptiert',
],
'honeypot' => [
'type' => 'honeypot',
'name' => 'company_check',
'rules' => ['nullable', 'string', 'max:0'],
],
]"
subject="Anfrage über Kontaktformular"
/>
```
**Wichtig**: Jedes Formular sollte ein Honeypot-Feld enthalten. Das Feld wird für Nutzer unsichtbar dargestellt; Bots füllen es oft aus und werden so erkannt.
### Eigene Presets in der Config
In `config/contact-form.php` können Sie eigene Presets definieren:
```php
'presets' => [
'mein-formular' => [
'name' => [
'type' => 'text',
'rules' => ['required', 'string', 'max:120'],
'label' => 'Name',
],
'email' => [
'type' => 'email',
'rules' => ['required', 'email:rfc,dns'],
'label' => 'E-Mail',
],
'message' => [
'type' => 'textarea',
'rules' => ['required', 'string', 'max:2000'],
'label' => 'Nachricht',
],
'privacy' => [
'type' => 'checkbox',
'rules' => ['accepted'],
'label' => 'Datenschutz',
],
'honeypot' => [
'type' => 'honeypot',
'name' => 'website',
'rules' => ['nullable', 'string', 'max:0'],
],
],
],
```
Verwendung:
```blade
<livewire:contact-form preset="mein-formular" />
```
## Spam-Schutz im Detail
| Maßnahme | Beschreibung |
|----------|--------------|
| **Honeypot** | Verstecktes Feld wenn ausgefüllt → Spam |
| **Zeitprüfung** | Formular < 3 Sek. ausgefüllt Spam |
| **Inhaltsanalyse** | Spam-Keywords, URLs, XSS-Muster → Spam |
| **Wegwerf-E-Mails** | tempmail.com, mailinator.com etc. → Spam |
| **Rate Limiting** | Max. 3 Anfragen/IP in 15 Min. (konfigurierbar) |
| **IP-Blacklist** | Blockierte IPs in Config |
| **Bot-User-Agent** | curl, wget, python-requests etc. → Spam |
Bei Spam wird **immer** eine Erfolgsmeldung angezeigt (Täuschung von Bots), aber **keine E-Mail** versendet. Alle Anfragen werden geloggt.
## Service und SpamDetector direkt nutzen
Falls Sie einen eigenen Controller oder eine eigene Livewire-Komponente verwenden möchten:
```php
use Acme\ContactForm\ContactFormService;
use Acme\ContactForm\SpamDetector;
// Spam prüfen
$spamDetector = SpamDetector::fromConfig();
$isSpam = $spamDetector->detect($validatedData, $formLoadedAt);
// Anfrage verarbeiten
$service = app(ContactFormService::class);
$service->handle([
'name' => $request->input('name'),
'email' => $request->input('email'),
'message' => $request->input('message'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'is_spam' => $isSpam,
], 'Betreff der E-Mail', 'Log-Kontext');
```
## Konfiguration
| Option | Beschreibung | Standard |
|--------|--------------|----------|
| `recipient` | E-Mail-Empfänger | aus .env |
| `rate_limit.max_attempts` | Max. Anfragen pro IP | 3 |
| `rate_limit.decay_minutes` | Zeitfenster in Minuten | 15 |
| `blacklisted_ips` | IP-Adressen blockieren | [] |
| `honeypot_fields` | Namen der Honeypot-Felder | company_check, website, url |
| `spam.min_fill_time_seconds` | Min. Zeit zum Ausfüllen | 3 |
| `spam.suspicious_patterns` | Regex für Spam-Erkennung | siehe Config |
| `spam.disposable_email_domains` | Wegwerf-Domains | siehe Config |
## Styling anpassen
Die Standard-Views nutzen generische Klassen (`border-gray-300`, `focus:ring-primary`). Passen Sie sie an Ihr Design an:
```bash
php artisan vendor:publish --tag=contact-form-views
```
Die Views liegen dann unter `resources/views/vendor/contact-form/`.
## Event nach Absenden
Nach erfolgreichem Absenden wird das Event `contact-form-submitted` dispatched. Sie können darauf reagieren:
```blade
<div x-data="{ submitted: false }"
x-on:contact-form-submitted.window="submitted = true">
<livewire:contact-form />
<template x-if="submitted">
<p>Vielen Dank für Ihre Nachricht!</p>
</template>
</div>
```
## Lizenz
MIT License

View file

@ -0,0 +1,38 @@
{
"name": "acme/contact-form",
"description": "Ein wiederverwendbares, spam-geschütztes Kontaktformular-Package für Laravel mit Livewire",
"type": "library",
"license": "MIT",
"keywords": [
"laravel",
"livewire",
"contact-form",
"spam-protection",
"honeypot"
],
"autoload": {
"psr-4": {
"Acme\\ContactForm\\": "src/"
}
},
"authors": [
{
"name": "Acme",
"email": "info@acme.de"
}
],
"require": {
"php": "^8.2",
"illuminate/support": "^10.0|^11.0|^12.0",
"livewire/livewire": "^3.0|^4.0"
},
"extra": {
"laravel": {
"providers": [
"Acme\\ContactForm\\ContactFormServiceProvider"
]
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

View file

@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Email Recipient
|--------------------------------------------------------------------------
|
| The email address that contact form submissions will be sent to.
|
*/
'recipient' => env('CONTACT_FORM_RECIPIENT', 'contact@example.com'),
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| Maximum number of submissions per IP within the given time window.
|
*/
'rate_limit' => [
'max_attempts' => env('CONTACT_FORM_MAX_ATTEMPTS', 3),
'decay_minutes' => env('CONTACT_FORM_DECAY_MINUTES', 15),
],
/*
|--------------------------------------------------------------------------
| IP Blacklist
|--------------------------------------------------------------------------
|
| IP addresses that will always be blocked as spam.
|
*/
'blacklisted_ips' => [],
/*
|--------------------------------------------------------------------------
| Spam Detection
|--------------------------------------------------------------------------
|
| Configuration for the built-in spam detection.
|
*/
'honeypot_fields' => ['company_check', 'website', 'url', 'website_url'],
'spam' => [
'min_fill_time_seconds' => env('CONTACT_FORM_MIN_FILL_TIME', 3),
'suspicious_patterns' => [
'/\b(viagra|cialis|casino|poker|lottery|bitcoin|crypto)\b/i',
'/\b[A-Z]{10,}\b/',
'/<script|<iframe|javascript:/i',
],
'max_links_in_message' => 2,
'max_repeated_chars' => 10,
'disposable_email_domains' => [
'tempmail.com',
'guerrillamail.com',
'10minutemail.com',
'mailinator.com',
'throwaway.email',
'yopmail.com',
],
],
/*
|--------------------------------------------------------------------------
| Form Presets
|--------------------------------------------------------------------------
|
| Predefined field configurations for different form types.
| See README for customization and defining your own forms.
|
*/
'presets' => [
'simple' => [
'name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:120'], 'label' => 'Name', 'required' => true],
'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true],
'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true],
'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true],
'honeypot' => ['type' => 'honeypot', 'name' => 'website', 'rules' => ['nullable', 'string', 'max:0']],
],
'full' => [
'first_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'First Name', 'required' => true],
'last_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'Last Name', 'required' => true],
'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true],
'phone' => ['type' => 'tel', 'rules' => ['nullable', 'string', 'max:80'], 'label' => 'Phone', 'required' => false],
'company' => ['type' => 'text', 'rules' => ['required', 'string', 'max:150'], 'label' => 'Company', 'required' => true],
'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true],
'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true],
'honeypot' => ['type' => 'honeypot', 'name' => 'company_check', 'rules' => ['nullable', 'string', 'max:0']],
],
'business' => [
'first_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'First Name', 'required' => true],
'last_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'Last Name', 'required' => true],
'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true],
'phone' => ['type' => 'tel', 'rules' => ['nullable', 'string', 'max:80'], 'label' => 'Phone', 'required' => false],
'company' => ['type' => 'text', 'rules' => ['required', 'string', 'max:150'], 'label' => 'Company', 'required' => true],
'project_type' => [
'type' => 'select',
'rules' => ['nullable', 'string', 'max:150'],
'label' => 'Project Type',
'options' => [
'consulting' => 'Consulting',
'implementation' => 'Implementation',
'support' => 'Support',
],
],
'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true],
'timeline' => [
'type' => 'select',
'rules' => ['nullable', 'string', 'max:150'],
'label' => 'Timeline',
'options' => [
'asap' => 'As soon as possible',
'3months' => 'Within 3 months',
'6months' => 'Within 6 months',
],
],
'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true],
'honeypot' => ['type' => 'honeypot', 'name' => 'company_check', 'rules' => ['nullable', 'string', 'max:0']],
],
],
];

View file

@ -0,0 +1,9 @@
<?php
return [
'default_subject' => 'Neue Kontaktanfrage',
'success_message' => 'Vielen Dank! Ihre Nachricht wurde erfolgreich gesendet.',
'submit' => 'Absenden',
'sending' => 'Wird gesendet...',
'select_placeholder' => 'Bitte wählen...',
];

View file

@ -0,0 +1,9 @@
<?php
return [
'default_subject' => 'New Contact Request',
'success_message' => 'Thank you! Your message has been sent successfully.',
'submit' => 'Submit',
'sending' => 'Sending...',
'select_placeholder' => 'Please select...',
];

View file

@ -0,0 +1,92 @@
<div>
<form wire:submit="submit" class="space-y-6">
@foreach ($fields as $key => $field)
@php
$name = $field['name'] ?? $key;
$label = $field['label'] ?? ucfirst(str_replace('_', ' ', $name));
$required = $field['required'] ?? (in_array('required', $field['rules'] ?? []));
$placeholder = $field['placeholder'] ?? '';
@endphp
@if ($field['type'] === 'honeypot')
<div class="opacity-0 absolute top-0 left-0 h-0 w-0 -z-10 overflow-hidden" aria-hidden="true">
<label for="{{ $name }}">{{ $label }}</label>
<input type="text" id="{{ $name }}" name="{{ $name }}"
wire:model="formData.{{ $name }}"
tabindex="-1" autocomplete="off">
</div>
@elseif ($field['type'] === 'textarea')
<div>
<label for="{{ $name }}" class="block text-sm font-medium mb-2">
{{ $label }}@if ($required) <span aria-hidden="true">*</span>@endif
</label>
<textarea id="{{ $name }}" name="{{ $name }}" rows="5"
wire:model="formData.{{ $name }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="{{ $placeholder }}"
@if ($required) required @endif></textarea>
@error("formData.{$name}")
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
@enderror
</div>
@elseif ($field['type'] === 'checkbox')
<div>
<div class="flex items-center gap-2">
<input type="checkbox" id="{{ $name }}" name="{{ $name }}"
wire:model="formData.{{ $name }}"
class="rounded border-gray-300"
@if ($required) required @endif>
<label for="{{ $name }}" class="text-sm">{{ $label }}</label>
</div>
@error("formData.{$name}")
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
@enderror
</div>
@elseif ($field['type'] === 'select')
<div>
<label for="{{ $name }}" class="block text-sm font-medium mb-2">
{{ $label }}@if ($required) <span aria-hidden="true">*</span>@endif
</label>
<select id="{{ $name }}" name="{{ $name }}"
wire:model="formData.{{ $name }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
@if ($required) required @endif>
<option value="">{{ $placeholder ?: __('contact-form::contact-form.select_placeholder') }}</option>
@foreach ($field['options'] ?? [] as $value => $optionLabel)
<option value="{{ $value }}">{{ $optionLabel }}</option>
@endforeach
</select>
@error("formData.{$name}")
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
@enderror
</div>
@else
<div>
<label for="{{ $name }}" class="block text-sm font-medium mb-2">
{{ $label }}@if ($required) <span aria-hidden="true">*</span>@endif
</label>
<input type="{{ $field['type'] ?? 'text' }}" id="{{ $name }}" name="{{ $name }}"
wire:model="formData.{{ $name }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="{{ $placeholder }}"
@if ($required) required @endif>
@error("formData.{$name}")
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
@enderror
</div>
@endif
@endforeach
@if ($success)
<div class="p-4 rounded-lg border border-green-200 bg-green-50 text-green-800 text-sm">
{{ __('contact-form::contact-form.success_message') }}
</div>
@endif
<button type="submit" wire:loading.attr="disabled"
class="w-full inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-70 disabled:cursor-not-allowed">
<span wire:loading.remove>{{ __('contact-form::contact-form.submit') }}</span>
<span wire:loading>{{ __('contact-form::contact-form.sending') }}</span>
</button>
</form>
</div>

View file

@ -0,0 +1,135 @@
<?php
namespace Acme\ContactForm;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class ContactFormService
{
public function __construct(
protected array $config = []
) {
$this->config = array_merge(
config('contact-form', []),
$config
);
}
/**
* Processes a contact form submission.
*/
public function handle(array $payload, string $subject = '', string $logContext = 'contact-form'): void
{
$isSpam = $payload['is_spam'] ?? false;
if (! $isSpam) {
$isSpam = $this->performServerSideSpamCheck($payload);
$payload['is_spam'] = $isSpam;
}
if ($isSpam) {
Log::info("SPAM detected - {$logContext}", $payload);
Log::info("Email NOT sent (spam detected - {$logContext})", [
'ip' => $payload['ip'] ?? 'unknown',
'email' => $payload['email'] ?? 'unknown',
]);
} else {
Log::info("{$logContext} received", $payload);
$this->notify($subject ?: __('contact-form::contact-form.default_subject'), $payload);
}
}
protected function notify(string $subject, array $payload): void
{
$recipient = $this->config['recipient'] ?? null;
if (empty($recipient)) {
return;
}
$body = $this->formatMailBody($subject, $payload);
try {
Mail::raw($body, function ($message) use ($recipient, $subject): void {
$message->to($recipient)->subject($subject);
});
} catch (\Throwable $e) {
Log::warning('Contact form email could not be sent', [
'error' => $e->getMessage(),
]);
}
}
protected function formatMailBody(string $subject, array $payload): string
{
$lines = [$subject, str_repeat('-', 40)];
foreach ($payload as $key => $value) {
if ($key === 'is_spam') {
continue;
}
$value = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string) $value;
$label = ucfirst(str_replace('_', ' ', $key));
$lines[] = "{$label}: {$value}";
}
return implode(PHP_EOL, $lines);
}
protected function performServerSideSpamCheck(array $payload): bool
{
$ip = $payload['ip'] ?? 'unknown';
$config = $this->config;
$maxAttempts = $config['rate_limit']['max_attempts'] ?? 3;
$decayMinutes = $config['rate_limit']['decay_minutes'] ?? 15;
$cacheKey = 'contact_form_'.md5($ip);
$attempts = cache()->get($cacheKey, 0);
if ($attempts >= $maxAttempts) {
Log::warning('Contact form rate limit exceeded', [
'ip' => $ip,
'attempts' => $attempts,
'max_attempts' => $maxAttempts,
]);
return true;
}
cache()->put($cacheKey, $attempts + 1, now()->addMinutes($decayMinutes));
$blacklistedIPs = $config['blacklisted_ips'] ?? [];
if (in_array($ip, $blacklistedIPs)) {
Log::warning('Blacklisted IP detected', ['ip' => $ip]);
return true;
}
$userAgent = $payload['user_agent'] ?? '';
$botPatterns = [
'/bot/i',
'/crawler/i',
'/spider/i',
'/scraper/i',
'/curl/i',
'/wget/i',
'/python-requests/i',
];
foreach ($botPatterns as $pattern) {
if (preg_match($pattern, $userAgent)) {
Log::warning('Bot user-agent detected', [
'ip' => $ip,
'user_agent' => $userAgent,
]);
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace Acme\ContactForm;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
class ContactFormServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(
__DIR__.'/../config/contact-form.php',
'contact-form'
);
$this->app->singleton(ContactFormService::class, function (): ContactFormService {
return new ContactFormService;
});
}
public function boot(): void
{
$this->loadViewsFrom(__DIR__.'/../resources/views', 'contact-form');
$this->loadTranslationsFrom(__DIR__.'/../lang', 'contact-form');
Livewire::component('contact-form', \Acme\ContactForm\Livewire\ContactForm::class);
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../config/contact-form.php' => config_path('contact-form.php'),
], 'contact-form-config');
$this->publishes([
__DIR__.'/../resources/views' => resource_path('views/vendor/contact-form'),
], 'contact-form-views');
$this->publishes([
__DIR__.'/../lang' => lang_path('vendor/contact-form'),
], 'contact-form-lang');
}
}
}

View file

@ -0,0 +1,109 @@
<?php
namespace Acme\ContactForm\Livewire;
use Acme\ContactForm\ContactFormService;
use Acme\ContactForm\SpamDetector;
use Illuminate\View\View;
use Livewire\Component;
class ContactForm extends Component
{
/** @var array<string, mixed> */
public array $formData = [];
public ?int $formLoadedAt = null;
public bool $success = false;
public string $preset = 'simple';
public string $subject = '';
public string $logContext = 'contact-form';
/**
* @var array<string, array{type: string, rules: array, label?: string, placeholder?: string, required?: bool, name?: string, options?: array}>
*/
public array $customFields = [];
public function mount(
string $preset = 'simple',
string $subject = '',
array $fields = []
): void {
$this->preset = $preset;
$this->subject = $subject;
$this->customFields = $fields;
$this->formLoadedAt = time();
$this->initializeFormData();
}
protected function getFields(): array
{
if (! empty($this->customFields)) {
return $this->customFields;
}
$presets = config('contact-form.presets', []);
return $presets[$this->preset] ?? $presets['simple'];
}
protected function initializeFormData(): void
{
foreach ($this->getFields() as $key => $field) {
$name = $field['name'] ?? $key;
$this->formData[$name] = match ($field['type']) {
'checkbox' => false,
default => '',
};
}
}
protected function getRules(): array
{
$rules = [];
foreach ($this->getFields() as $key => $field) {
$name = $field['name'] ?? $key;
$rules["formData.{$name}"] = $field['rules'] ?? ['nullable'];
}
return $rules;
}
public function submit(ContactFormService $service): void
{
$validated = $this->validate($this->getRules());
$flattened = $validated['formData'] ?? [];
$spamDetector = SpamDetector::fromConfig();
$isSpam = $spamDetector->detect($flattened, $this->formLoadedAt);
$payload = array_merge($flattened, [
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'is_spam' => $isSpam,
]);
$service->handle($payload, $this->subject, $this->logContext);
$this->success = true;
$this->dispatch('contact-form-submitted');
$this->resetForm();
}
protected function resetForm(): void
{
$this->initializeFormData();
$this->formLoadedAt = time();
}
public function render(): View
{
return view('contact-form::form', [
'fields' => $this->getFields(),
]);
}
}

View file

@ -0,0 +1,165 @@
<?php
namespace Acme\ContactForm;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class SpamDetector
{
public function __construct(
protected array $config
) {}
/**
* Checks the given data for spam indicators.
*/
public function detect(array $validated, ?int $formLoadedAt = null, ?Request $request = null): bool
{
$request = $request ?? request();
if ($this->checkHoneypot($validated, $request)) {
return true;
}
if ($this->checkFillTime($formLoadedAt, $request)) {
return true;
}
if ($this->checkContent($validated, $request)) {
return true;
}
if ($this->checkDisposableEmail($validated, $request)) {
return true;
}
return false;
}
/**
* Checks honeypot fields configured fields must be empty.
*/
protected function checkHoneypot(array $validated, Request $request): bool
{
$honeypotFields = $this->config['honeypot_fields'] ?? ['company_check', 'website', 'url', 'website_url'];
foreach ($honeypotFields as $field) {
if (isset($validated[$field]) && ! empty($validated[$field])) {
$this->logSpam('Honeypot field filled', ['field' => $field], $request);
return true;
}
}
foreach ($validated as $key => $value) {
if (str_contains((string) $key, '_check') && ! empty($value)) {
$this->logSpam('Honeypot field filled', ['field' => $key], $request);
return true;
}
}
return false;
}
/**
* Time-based check: form submitted too quickly.
*/
protected function checkFillTime(?int $formLoadedAt, Request $request): bool
{
if ($formLoadedAt === null) {
return false;
}
$minSeconds = $this->config['spam']['min_fill_time_seconds'] ?? 3;
$timeSpent = time() - $formLoadedAt;
if ($timeSpent < $minSeconds) {
$this->logSpam('Form submitted too quickly', ['time_spent' => $timeSpent], $request);
return true;
}
return false;
}
/**
* Content analysis: suspicious patterns, excessive links, repeated characters, XSS.
*/
protected function checkContent(array $validated, Request $request): bool
{
$patterns = $this->config['spam']['suspicious_patterns'] ?? [];
$maxLinks = $this->config['spam']['max_links_in_message'] ?? 2;
$maxRepeated = $this->config['spam']['max_repeated_chars'] ?? 10;
foreach ($validated as $key => $value) {
if (! is_string($value) || str_contains($key, 'honeypot')) {
continue;
}
foreach ($patterns as $pattern) {
if (preg_match($pattern, $value)) {
$this->logSpam('Suspicious content detected', ['pattern' => $pattern, 'field' => $key], $request);
return true;
}
}
$linkCount = substr_count(strtolower($value), 'http://') +
substr_count(strtolower($value), 'https://') +
substr_count(strtolower($value), 'www.');
if ($linkCount > $maxLinks) {
$this->logSpam('Too many links', ['link_count' => $linkCount], $request);
return true;
}
if (preg_match('/(.)\1{'.($maxRepeated + 1).',}/', $value)) {
$this->logSpam('Too many repeated characters', [], $request);
return true;
}
}
return false;
}
/**
* Checks for disposable/throwaway email domains.
*/
protected function checkDisposableEmail(array $validated, Request $request): bool
{
$email = $validated['email'] ?? $validated['e-mail'] ?? null;
if (empty($email) || ! is_string($email)) {
return false;
}
$domains = $this->config['spam']['disposable_email_domains'] ?? [];
$emailDomain = substr((string) strrchr($email, '@'), 1);
if (in_array(strtolower($emailDomain), $domains)) {
$this->logSpam('Disposable email address', ['domain' => $emailDomain], $request);
return true;
}
return false;
}
protected function logSpam(string $reason, array $context, Request $request): void
{
Log::warning('Spam detected (contact-form): '.$reason, array_merge([
'ip' => $request->ip(),
], $context));
}
/**
* Static factory for convenient instantiation.
*/
public static function fromConfig(?array $config = null): self
{
return new self($config ?? config('contact-form', []));
}
}