djl997 / simple-laravel-form-protection
Heuristic spam scoring and submission monitoring for public Laravel forms.
Package info
github.com/djl997/simple-laravel-form-protection
pkg:composer/djl997/simple-laravel-form-protection
Requires
- php: ^8.3
- illuminate/console: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- livewire/livewire: ^3.0|^4.0
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5
Suggests
- livewire/livewire: Required only for the UsesFormSubmission trait; plain HTML forms use the FormProtection facade instead.
- spatie/laravel-honeypot: Adds honeypot form fields as a first line of defence.
README
Heuristic spam scoring and submission monitoring for public Laravel forms.
Every public form submission is stored, scored against a set of configurable heuristics, and flagged when the score reaches a threshold. The intent is monitoring: nothing is blocked or rejected: your application decides what to do with a flagged submission (typically: store it, but skip the notification mail).
This package pairs well with, but does not replace, spatie/laravel-honeypot. Honeypot fields stop naive bots outright; this package scores whatever gets past them.
Installation
While the package lives locally, add it as a path repository:
"repositories": [ { "type": "path", "url": "./package" } ]
composer require djl997/simple-laravel-form-protection:^0.1
php artisan vendor:publish --tag=simple-laravel-form-protection-migrations
php artisan vendor:publish --tag=simple-laravel-form-protection-config
php artisan migrate
# optional, only to customise the <x-form-protection::meta /> markup
php artisan vendor:publish --tag=simple-laravel-form-protection-views
Usage
The package works with both Livewire components and plain HTML forms. Both paths score and store submissions identically; they differ only in how the form's render time reaches the scorer.
Livewire
Add the trait to a Livewire component, give it a form key, and call mountMeta()
when the form is rendered:
use Djl997\SimpleLaravelFormProtection\Concerns\UsesFormSubmission; class ContactForm extends Component { use UsesFormSubmission; protected string $form_key = 'contact'; public function mount(): void { $this->mountMeta(); } public function submit() { $this->validate(); $submission = $this->createFormSubmission([ 'name' => $this->name, 'email' => $this->email, 'message' => $this->message, ]); if (! $submission->is_spam) { Mail::to('info@example.com')->send(new ContactFormSubmitted(...)); } } }
mountMeta() records the request path and the moment the form was rendered. That
render time drives the timing heuristic, so a component that never calls it simply
scores nothing for timing rather than being penalised.
Plain HTML forms
A Livewire component keeps its render time in component state. A plain form has no
such state — render and submit are two separate requests — so the render time travels
in an encrypted hidden field. Drop the Blade component inside your <form>:
<form method="POST" action="/contact"> @csrf <x-form-protection::meta form-key="contact" /> {{-- your fields --}} </form>
Then record the submission in the controller with the FormProtection facade:
use Djl997\SimpleLaravelFormProtection\Facades\FormProtection; use Djl997\SimpleLaravelFormProtection\Scoring\SpamScorer; public function store(Request $request) { $data = $request->validate([ 'name' => ['required', 'string'], 'email' => ['required', 'email'], 'message' => ['required', 'string'], ]); $scorer = app(SpamScorer::class); $content_score = $scorer->scoreEmail($data['email']) + $scorer->scoreMessage($data['message']); $submission = FormProtection::record($request, 'contact', $data, $content_score); if (! $submission->is_spam) { Mail::to('info@example.com')->send(new ContactFormSubmitted($submission)); } return back()->with('status', 'Bedankt voor je bericht!'); }
There is no calculateContentScore() hook here, since there is no component to
override it on. Call the SpamScorer helpers directly for whichever fields the form
has — they are the same methods the trait's helpers delegate to, and they ignore
blank values.
The hidden field is encrypted with your application key and bound to the form key, so
it cannot be tampered with or reused across forms. It also expires after
timing.max_token_age (12 hours by default) so a token harvested once cannot be
replayed forever.
A token that is missing, forged, stale, or issued for another form is not treated as spam — it scores as if the timing were never measured. This keeps the package's promise that it only ever monitors. A form that omits the Blade component still works; it just loses the timing signal.
Scoring the content
In a Livewire component, override calculateContentScore() to score the submitted
fields. Three helpers cover the common cases:
public function calculateContentScore(int $content_score = 0): int { $content_score = $this->scoreEmail($this->email, $content_score); $content_score = $this->scorePhone($this->phone, $content_score); return $this->scoreMessage($this->message, $content_score); }
Each helper takes the value to score plus the running total, and returns the new
total. They ignore blank values. For plain HTML forms, call the equivalent
SpamScorer methods in the controller as shown above.
Thresholds
A submission is flagged when its score reaches the threshold. The default lives in
config; a Livewire component can tighten it by overriding the method, and a plain form
can pass a threshold as the fifth argument to FormProtection::record():
public function spamScoreThreshold(): int { return 4; }
Keep this method
public. The trait declares itpublic, and narrowing it toprotectedin a subclass is a fatal error in PHP.
Configuration
config/simple-laravel-form-protection.php holds the threshold, the model class, and every
weight and magic value the heuristics use. Defaults are tuned for a Dutch/Belgian
audience:
| Rule | Triggers when | Default weight |
|---|---|---|
| Timing | Submitted under 5 seconds after render | 3 |
| Timing (plain HTML) | Token older than timing.max_token_age scores as unmeasured |
— |
| Email provider | Contains gmail, yahoo, .fr, .ca, .de, .ru |
1 |
| Email TLD | Contains none of .nl, .be, .com, .net |
1 |
| Phone | Does not start with 06 or +31 |
1 |
| Message spacing | 3 or fewer whitespace characters | 2 (+1 if over 20 chars) |
| Message length | 27 characters or fewer | 2 |
| Punctuation | Contains neither . nor , |
2 |
Set any weight to 0 to disable that rule. Tune the lists to your own audience —
the defaults penalise perfectly ordinary submissions from outside the Benelux.
Retention
Genuine submissions you soft-delete are pruned after pruning.retention_days via
Laravel's own model:prune. Spam is kept for review and then permanently deleted:
php artisan form-protection:purge # honours pruning.spam_after_days
php artisan form-protection:purge --days=7
Schedule both in routes/console.php:
Schedule::command('model:prune')->daily(); Schedule::command('form-protection:purge')->daily();
Extending the model
Point model in the config at your own class extending
Djl997\SimpleLaravelFormProtection\Models\FormSubmission to add relations or scopes. The
packaged model records user_id for authenticated submissions but deliberately
declares no foreign key, since it cannot know your users table.
Testing
composer install vendor/bin/phpunit