Uniqueness and duplicate prevention
Paid add-on. One rule per form decides whether an answer already received should be refused or merely flagged, on a key you designate: an address, a membership number, a combination of fields.
This is not anti-spam, and that is the first thing to understand
Anti-spam asks “is this a robot”. This rule asks “has this person already answered”. Both get it wrong, but not in the same way.
A false positive in anti-spam loses a message: that is annoying, and the message still exists somewhere. A false positive in uniqueness stops someone from signing up, and that person has no way around the refusal — they can neither change their address nor guess why they are being turned away.
The whole module follows from that asymmetry:
- exact comparison, never approximate;
- a normalisation that fits in one sentence;
- flag by default, not block;
- a public message that tells nobody anything.
The plan rules them out in a few words — “no fuzzy matching, fraud scoring, cross-site comparison or IP blocking” — and none of those four exists here.
The key
You designate one to five fields. Their values, end to end, form the key. Two answers producing the same key are the same person, as far as this rule is concerned.
The order is the form’s, not the one in which you tick the fields: two settings naming the same fields in a different order produce the same key, failing which changing the setting without changing anything else would invalidate the whole history.
What the normalisation does, and stops doing
Leading and trailing spaces are removed, runs of spaces collapsed to one, letter case lowered. That is all.
Camille@Example.com and camille@example.com are therefore the same key —
that is the only equivalence one can assert without knowing the field, since
every mail server in use treats case that way.
On the other hand, jean.dupont@ and jeandupont@ stay two keys. Removing
dots merges two addresses at one provider and two distinct mailboxes at another.
Likewise, accents stay: René is not Rene.
Every additional matching rule has its false positives, and a false positive here stops someone from signing up.
Fields that cannot form a key
A file, a signature and a payment never repeat identically: a new path, a new stroke, a new reference. A password leaves through none of the plugin’s outputs.
The calculated field is excluded for another reason: its value depends on a formula that can change. The day it changes, every key already stored stops matching, and the rule stops working with nothing to signal it.
What is kept
A fingerprint, never the value. It is a HMAC-SHA256 taken under a site salt: it cannot be reversed, and it is good for this installation only.
A plain hash would not have been enough. The list of a country’s email addresses fits on a disk; comparing simple fingerprints is a minute’s work. The salt closes that, and closes along the way the cross-site comparison the plan rules out.
A consequence worth knowing: regenerating the salts in
wp-config.phpmakes the fingerprints already stored unusable. The rules start again from zero. That is inconvenient, never destructive — no submission is lost.
Block or flag
Flag is the default. The answer is recorded and marked; you see it on the “Duplicates” screen and in the submission detail. It suits a contact form: you want to receive the message, and to know that it resembles an earlier one.
Block refuses the answer. It suits a registration, where a second attempt has no reason to exist.
Starting with “flag” is not prudence as a matter of principle: it is the only way to see what the rule catches before it turns somebody away.
The message says nothing
Not when, not by whom, not under what value the first answer came in. On a public form, “this address already answered on 3 March” tells exactly the person trying addresses at random what they wanted to know.
The admin screen does show the match: that is where it belongs.
The window
In days. Zero means forever — that is not the absence of a rule, it is the
strictest one. 365 allows one answer per person per year.
A hold whose window has passed is reclaimed by itself, on the next submission: there is no housekeeping task, and therefore nothing that can fail to run.
Two submissions at the same instant
That is the module’s reason for existing, and the only place where a naive implementation gets it wrong.
“Look up whether the key exists, and write it otherwise” is correct as long as a single request runs. Two simultaneous submissions — a double click, two tabs, a bot — both read “none”, and both write.
The claim is therefore a single statement, an
INSERT … ON DUPLICATE KEY UPDATE on a unique index. The database server
decides, one request leaves with the key, and the number of affected rows says
which. No check placed beside the write would have held.
The key is claimed before the submission exists
That is the only way to refuse a duplicate without first recording what you are about to refuse. The claim therefore lives for a moment with no submission attached.
If recording fails in between, the row would stay there blocking the person for the whole window. An unattached claim is therefore abandoned after a minute, and reclaimable — generous for a request lasting hundredths of a second, short for somebody trying again. The module also releases it itself at the end of the request when it can.
The exemptions
A submission trashed, marked as spam or deleted gives its key back. Without that, somebody would stay unable to answer because of an entry the administrator had precisely removed from their site, and they would have no way of understanding why.
A draft never claims a key: it is not an answer. It is the resumption of the draft, at the moment it becomes the submission, that claims it.
Restoring from the trash does not take the key back. This is an accepted limit: reclaiming it could fail if somebody took it in the meantime, and a restoration that half fails would be worse than the gap it fills.
The screen
“Duplicates”, under the forms menu. It shows the number of keys held, the number of flagged duplicates, and the list of the latter with a link to the submission they match.
Blocked submissions do not appear there, and cannot: they were never recorded, which is the whole point of blocking. Whoever wants a count sets the rule to “flag”.
The value that links two submissions is not there either — only the fingerprint is kept. You open both submissions to read it, where it belongs and where it is subject to the same deletion as the rest.
For developers
This module asked one extension point of the core:
solis_forms_entry_status_changed, emitted by the repository when a submission’s
status really changes. It carries the state left and the new one, because the
first cannot be deduced — a submission can go from trash to spam without passing
back through active.
It is documented in hooks-reference.md, and serves any
module that has to undo something when a submission leaves the active state.
What V1 does not do
One rule per form. No fuzzy matching, no scoring, no comparison between forms or between sites, no IP blocking, no per-person exception list, no key reclaim on restore.
