11/19/2014
A card expiry month/year field in Symfony
A card expiry is a month and a year. DateType is a day, a month and a
year, and it does not have an option to drop the day — which is why the
workaround that circulated for years was to render the day dropdown and
hide it with CSS.
That is fine until somebody reads the page with a screen reader, or styles fail to load, at which point there is a dropdown asking for a day that does not exist. Two plain choice fields are the honest version:
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
$builder
->add('expiryMonth', ChoiceType::class, [
'choices' => array_combine(
array_map(fn ($m) => str_pad((string) $m, 2, '0', STR_PAD_LEFT), range(1, 12)),
range(1, 12),
),
'placeholder' => 'Month',
])
->add('expiryYear', ChoiceType::class, [
'choices' => array_combine(
range(date('Y'), date('Y') + 12),
range(date('Y'), date('Y') + 12),
),
'placeholder' => 'Year',
]);
placeholder is what empty_value became in Symfony 2.6; the old name
was removed in 3.0. If you are copying an example that still uses
empty_value, that is the age of the example.
If the column really is a date
Two form fields do not have to mean two database columns. A transformer
maps the pair back onto one DateTimeImmutable, with the day pinned to
the first:
$builder->addModelTransformer(new CallbackTransformer(
fn (?\DateTimeImmutable $date) => $date === null ? null : [
'expiryMonth' => (int) $date->format('n'),
'expiryYear' => (int) $date->format('Y'),
],
fn (?array $parts) => $parts === null ? null : new \DateTimeImmutable(
sprintf('%04d-%02d-01', $parts['expiryYear'], $parts['expiryMonth']),
),
));
A card is valid through the end of its expiry month, so store the first and compare with that in mind, or store the last day of the month and be done with it. Getting this wrong declines good cards for up to thirty days, which is an expensive off-by-one.
The DateType route, done properly
If you would rather keep one DateType, configure it rather than hiding
part of it:
use Symfony\Component\Form\Extension\Core\Type\DateType;
->add('expiry', DateType::class, [
'widget' => 'choice',
'format' => 'MMyyyy',
'years' => range(date('Y'), date('Y') + 12),
'days' => [1],
'placeholder' => ['year' => 'Year', 'month' => 'Month'],
])
'days' => [1] leaves the day as a single fixed option rather than a list
of thirty-one, and format without a d keeps it out of the ordering.
It is still a control the user does not need, which is why the two-field
version above is the one to reach for on new work.
One thing not to do
Do not store the card number alongside this. Expiry month and year are ordinary data; the number is not, and keeping it puts you inside PCI DSS scope for the whole application. Every payment provider worth using hands back a token instead, and the token is what your entity should hold.