djl997/simple-laravel-form-protection

Heuristic spam scoring and submission monitoring for public Laravel forms.

Maintainers

Package info

github.com/djl997/simple-laravel-form-protection

pkg:composer/djl997/simple-laravel-form-protection

Transparency log

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.1.0 2026-07-20 13:22 UTC

This package is auto-updated.

Last update: 2026-07-20 13:43:51 UTC


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 it public, and narrowing it to protected in 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