FormSubmissionGuard
in package
One-time submission tokens for forms that create a record ("add" forms), so a double click or two concurrent POSTs of the same form create one record, not two.
The form carries a random token issued when it is rendered (issueToken()). The first submit
claims it by inserting a row into cms_system_form_submissions; the primary key makes the claim atomic
across concurrent requests and processes. Neither the session nor Redis can do that here: sessions live
in Redis without locking, so two requests of one session read the same snapshot and both would see the
token as unused.
Lifecycle of a claimed token:
- complete() after a successful save stores the created record id; a later submit of the same token gets SubmissionClaim::Done with that id and is redirected to the record instead of saving;
- release() after a failed save deletes the row, so the corrected form can be sent again with the same token (it is still in the re-rendered hidden field);
- a submit arriving while the first one still runs gets SubmissionClaim::InProgress; awaitOutcome() waits a few seconds for the first request to finish.
Fail-open, like CronLockManager: a database error (missing
table before the migration ran, outage) must not block saving, so the claim then passes as
SubmissionClaim::Claimed and the failure is logged. Portable SQL only (plain INSERT plus the
unique violation, no INSERT IGNORE), so the unit test runs on SQLite.
Table of Contents
Constants
- FIELD : string = 'submissionToken'
- Name of the hidden form field carrying the token.
- PURGE_LIMIT : int = 200
- Upper bound of rows purged per claim, so a backlog never makes one submit slow.
- RETENTION_SECONDS : int = 86400
- Rows older than this are purged on the next claim; a form left open longer gets a fresh token on reload.
- STATE_DONE : int = 1
- STATE_PENDING : int = 0
- TABLE : string = 'cms_system_form_submissions'
- TOKEN_LENGTH : int = 32
Properties
- $connection : Connection
Methods
- __construct() : mixed
- awaitOutcome() : SubmissionOutcome
- Waits until the submit that claimed the token first finishes, polling the row.
- claim() : SubmissionOutcome
- Claims the token for the current submit.
- complete() : void
- Marks the token as used by a successful save of the record `$entityId` (null when the form has no record id).
- issueToken() : string
- Returns a new random token for the hidden field of a freshly rendered form.
- isWellFormed() : bool
- Tells whether the submitted value has the shape of a token this class issues (anything else is rejected).
- release() : void
- Frees the token after a failed save, so the corrected form can be submitted again.
- now() : string
- purgeExpired() : void
- read() : SubmissionOutcome|null
- Returns the state of a claimed token, or null when no row exists (never claimed, released or purged).
- retryClaim() : SubmissionOutcome
- The row vanished between the failed insert and the read (released in between): one more attempt.
Constants
FIELD
Name of the hidden form field carrying the token.
public
string
FIELD
= 'submissionToken'
PURGE_LIMIT
Upper bound of rows purged per claim, so a backlog never makes one submit slow.
private
int
PURGE_LIMIT
= 200
RETENTION_SECONDS
Rows older than this are purged on the next claim; a form left open longer gets a fresh token on reload.
private
int
RETENTION_SECONDS
= 86400
STATE_DONE
private
int
STATE_DONE
= 1
STATE_PENDING
private
int
STATE_PENDING
= 0
TABLE
private
string
TABLE
= 'cms_system_form_submissions'
TOKEN_LENGTH
private
int
TOKEN_LENGTH
= 32
Properties
$connection read-only
private
Connection
$connection
Methods
__construct()
public
__construct(Connection $connection) : mixed
Parameters
- $connection : Connection
awaitOutcome()
Waits until the submit that claimed the token first finishes, polling the row.
public
awaitOutcome(string $token, string $form[, float $timeoutSeconds = 10.0 ][, int $pollMicroseconds = 200000 ]) : SubmissionOutcome
Returns SubmissionClaim::Done once it saved, SubmissionClaim::Claimed when it failed (the row was released and this call claimed it for the caller), or SubmissionClaim::InProgress after the timeout.
Parameters
- $token : string
- $form : string
- $timeoutSeconds : float = 10.0
- $pollMicroseconds : int = 200000
Return values
SubmissionOutcomeclaim()
Claims the token for the current submit.
public
claim(string $token, string $form) : SubmissionOutcome
Parameters
- $token : string
- $form : string
-
identifies the form (presenter and action) only for diagnostics; the token is unique on its own
Return values
SubmissionOutcomecomplete()
Marks the token as used by a successful save of the record `$entityId` (null when the form has no record id).
public
complete(string $token, int|null $entityId) : void
Parameters
- $token : string
- $entityId : int|null
issueToken()
Returns a new random token for the hidden field of a freshly rendered form.
public
issueToken() : string
Return values
stringisWellFormed()
Tells whether the submitted value has the shape of a token this class issues (anything else is rejected).
public
isWellFormed(mixed $token) : bool
Parameters
- $token : mixed
Return values
boolrelease()
Frees the token after a failed save, so the corrected form can be submitted again.
public
release(string $token) : void
Parameters
- $token : string
now()
private
now() : string
Return values
stringpurgeExpired()
private
purgeExpired() : void
read()
Returns the state of a claimed token, or null when no row exists (never claimed, released or purged).
private
read(string $token) : SubmissionOutcome|null
Parameters
- $token : string
Return values
SubmissionOutcome|nullretryClaim()
The row vanished between the failed insert and the read (released in between): one more attempt.
private
retryClaim(string $token, string $form) : SubmissionOutcome
Parameters
- $token : string
- $form : string