BackdateGuard
\Benecaster\Token\BackdateGuard
Validates a backdated created_at before it is written to a token row. A migration can carry a subscriber’s original join date across from another platform, and the tenure badge a subscriber sees is calculated from that column, so a bad value from a CSV parsing mistake would produce a wrong badge (a 10-Year Member, say) in front of the person it is wrong about. The guard refuses three kinds of value: one it cannot read, one in the future, and one before the floor.
This is mostly internal. TokenRepository::insert() and TokenRepository::set_created_at() both run every backdated value through normalize(), so no write path can skip it, and TokenManager::generate() passes its optional $created_at argument down to that check. It is useful directly to code that imports many rows: run reason() over the whole file first, show the operator one list of every bad row, and only then call generate() for the good ones. Catching an exception per row instead can leave an import partly written, and because generate() discards the plaintext token when it throws, the feed URL for the failed row cannot be reproduced.
A rejected date is reported and the row is not written. The guard never clamps a date to the floor: a migration that quietly rewrote 400 join dates to the same value would look identical to one that worked, and the podcaster would find out months later from a subscriber.
Construct it directly with no arguments. The one optional constructor parameter is a UTC “now” string, so a test can pin the future-date boundary.
Methods
| Method | Visibility | Since | Description |
|---|---|---|---|
reason( string $created_at ): ?string |
Public | — | Returns why the value is unacceptable as one of the REASON_* codes, or null when it is fine. It never throws, and returns a code rather than a sentence so the caller can group and translate its own report. This is the method to loop over a file with. |
normalize( string $created_at ): string |
Public | — | Returns the value as a canonical Y-m-d H:i:s string, or throws InvalidArgumentException with a message naming the value and the rule it broke. A bare Y-m-d widens to midnight UTC. Ask reason() first if you want to report rather than abort. |
Constants
| Name | Value | Description |
|---|---|---|
FLOOR |
'2004-01-01 00:00:00' |
The earliest created_at a token may claim, in UTC. The floor is the earliest date at which the relationship being imported could have existed at all, not the earliest plausible customer. A podcast subscriber cannot predate podcasting, and 2004 is the year the medium acquired its name and its first clients. It is set generously on purpose, so the failure mode is letting a strange but genuine date through rather than refusing a podcaster's oldest supporter. What it catches is a mis-parsed CSV cell: 0000-00-00, a Unix epoch 1970-01-01 standing in for an empty field, a two-digit year widened to 0022, or a day and month swap landing in the wrong millennium. |
REASON_UNPARSEABLE |
'unparseable' |
Not a date the guard can read. Only Y-m-d H:i:s and bare Y-m-d are accepted, and no other format is guessed at. |
REASON_FUTURE |
'future' |
A real date later than now. Tenure is time elapsed since the date, so a future date would make it negative. |
REASON_BEFORE_FLOOR |
'before_floor' |
A real date older than FLOOR. |
Notes
Accepted formats are Y-m-d H:i:s and bare Y-m-d. The bare form is accepted because it is what a CSV export typically contains, and it widens to midnight UTC; that widening is the only interpretation the guard performs. Impossible dates are refused rather than rolled forward: PHP's date parsing turns 2022-02-30 into 2 March, so the guard re-formats what it parsed and compares it with the input.
The comparison and the floor are both in UTC. created_at is a machine event stamp, and the other machine stamps on the tokens table are UTC, so the guard measures "now" with gmdate() and not with the site's timezone. A backdated value therefore sits on the same clock as the rows that were stamped by the column default.
There is no filter on the floor. It is a correctness bound, not a preference, and a unit test pins that no filter can lift it. A site that needs a different floor has to change the plugin's code.