ccgenerator / test-cards
Synthetic, Luhn-valid payment card numbers for testing card input. Framework-agnostic core with first-class Laravel and Symfony integration: validation rules, a Faker provider, console commands. No dependencies, no network calls, no real card data.
Requires
- php: ^8.1
Requires (Dev)
- fakerphp/faker: ^1.23
- orchestra/testbench: ^8.0 || ^9.0 || ^10.0 || ^11.0
- phpunit/phpunit: ^10.5 || ^11.0 || ^12.0
- symfony/config: ^6.4 || ^7.0
- symfony/console: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
- symfony/validator: ^6.4 || ^7.0
Suggests
- fakerphp/faker: Adds testCardNumber() and friends to model factories (auto-registered in Laravel)
- illuminate/support: Enables the Laravel service provider, facade, validation rule and Artisan commands
- symfony/console: Enables the bin/console commands
- symfony/http-kernel: Enables the Symfony bundle
- symfony/validator: Enables the TestCardNumber constraint
This package is auto-updated.
Last update: 2026-08-23 17:28:02 UTC
README
Synthetic, Luhn-valid payment card numbers for testing card input, with first-class Laravel and Symfony integration.
Zero runtime dependencies. No network calls. No real card data.
use CCGenerator\TestCards\TestCards; $card = TestCards::generate('mastercard'); $card->pan; // "5393016145498473" $card->formatted(); // "5393 0161 4549 8473" $card->cvv; // "695" $card->expiry(); // "09/30" TestCards::detectBrand('2223 0031 2200 3222')->value; // "mastercard" TestCards::validate('4111111111111112')->errors; // ["luhn_failed"]
Contents
- What this is for · What it is not
- Install
- Quick start
- Laravel — validation · factories · Artisan · tests
- Symfony — constraint · services · console
- Standalone CLI
- API reference
- Supported networks
- Why the ranges are what they are
- Reproducible fixtures
- Related tools · Background reading
What this is for
Exercising the card field. Does the input mask group digits correctly? Does the
brand icon switch on the right prefix? Does a 19-digit Maestro number survive
the form, and does a 15-digit Amex number get an Amex-length CVV field? Does
your card_number validation rule reject a single mistyped digit?
Those are the questions this package answers, and it is deliberately not useful for anything else.
What it is not
The numbers are not real cards. They satisfy the Luhn checksum and sit inside published issuer identification ranges, which is exactly what a card field checks — and that is where the resemblance stops. They correspond to no account at any issuer, carry no balance, and cannot authorise a transaction. A payment gateway declines them at the first hop. The mechanics are spelled out in why generated cards have no balance and why test cards fail on real payment systems.
They are not gateway test cards either. Stripe, PayPal, Adyen and the rest publish their own fixed numbers that trigger specific sandbox behaviour — a decline for insufficient funds, a 3-D Secure challenge, a disputed charge. This package cannot produce those, because they are assigned by the gateway rather than derived from any rule. Use the provider's own list: test card numbers by gateway collects them, and there are dedicated notes for Stripe, PayPal sandbox and 3-D Secure.
It is not a fraud tool. Generating structurally valid numbers is a routine part of building payment forms, and it is also the point at which people ask whether it is legal — the short answer is that the numbers are inert, and attempting to use them for a purchase is fraud regardless of where they came from.
Install
composer require --dev ccgenerator/test-cards
--dev is the right flag for most projects: this is test data. Drop it only if
your production code needs the validator or the brand detector — both are pure
functions over a rule table, with no I/O.
| Requirement | Supported |
|---|---|
| PHP | 8.1 – 8.5 |
| Laravel | 11, 12, 13 (auto-discovered) — needs PHP 8.2+, because Laravel 11 does |
| Symfony | 6.4 LTS, 7.x — PHP 8.1 included |
| Runtime dependencies | none |
The Laravel bridge still runs on Laravel 10, but that release is end-of-life and Composer now refuses to install it over security advisories, so it is no longer covered by CI.
Quick start (any PHP project)
use CCGenerator\TestCards\Brand; use CCGenerator\TestCards\TestCards; // One card, random network $card = TestCards::generate(); // A specific network — string or enum, whichever suits the caller TestCards::generate('amex'); TestCards::generate(Brand::Amex); // A length that network actually issues TestCards::generate('maestro', length: 19); // A batch for a data provider $cards = TestCards::many(100, 'visa'); // One of every network: 14, 15, 16 and 19 digits, 3 and 4-digit CVVs $corpus = TestCards::oneOfEach(); // A number that looks right and fails the checksum $typo = TestCards::invalid('visa'); // Structural validation $result = TestCards::validate('4111 1111 1111 1111'); $result->valid; // true $result->brand; // Brand::Visa $result->errors; // []
TestCards is a static shortcut. In application code with a container, inject
CCGenerator\TestCards\CardService instead — same API, and it can be swapped in
a test.
Laravel
The service provider and the TestCards facade are registered by package
discovery. There is nothing to add to config/app.php.
Publish the config only if you want to change something:
php artisan vendor:publish --tag=test-cards-config
// config/test-cards.php return [ 'seed' => env('TEST_CARDS_SEED'), // null = fresh numbers every run 'expiry_years_ahead' => 5, 'accepted_networks' => [], // [] = every network in the table ];
Generating
use TestCards; // the facade alias use CCGenerator\TestCards\CardService; // or inject the service class CheckoutSeeder { public function __construct(private CardService $cards) {} public function run(): void { $card = $this->cards->generate('visa'); // ... } } TestCards::generate('amex'); TestCards::many(50); TestCards::validate($request->input('card_number'));
The facade resolves out of the container, so it mocks the usual way:
TestCards::shouldReceive('generate')->andReturn($knownFixture);
Validation
Two ways in, depending on how much the failure message matters.
The string rule composes with everything else:
$request->validate([ 'card_number' => 'required|test_card', 'amex_field' => 'required|test_card:amex', 'either' => 'required|test_card:visa,mastercard', ]);
With no parameters it falls back to config('test-cards.accepted_networks'), so
one config entry can govern every card field in the application.
The rule object says why it failed, which is the difference between a card field that feels broken and one that does not:
use CCGenerator\TestCards\Laravel\Rules\TestCardNumber; $request->validate([ 'card_number' => ['required', new TestCardNumber()], 'amex_only' => ['required', TestCardNumber::only('amex')], // A stored, partially masked number: structure yes, checksum no 'on_file' => ['required', new TestCardNumber(requireLuhn: false)], ]);
Each failure has its own message:
| Input | Message |
|---|---|
4111111111111112 |
The card number field is not a valid card number. Check for a mistyped digit. |
3782822463100516 |
The card number field must be 15 digits for American Express. |
4111… with only('amex') |
The card number field must be one of: American Express. |
4111-x111… |
The card number field must contain digits only. |
English and Turkish messages ship with the package. To reword them, or to add another locale, publish the language files:
php artisan vendor:publish --tag=test-cards-lang
# -> lang/vendor/test-cards/{en,tr}/validation.php
Anything you drop in lang/vendor/test-cards/<locale>/validation.php wins over
the packaged copy. Translations for other locales are welcome as PRs.
Model factories with Faker
The Faker provider registers itself when fakerphp/faker is installed, so
factories can use it directly:
// database/factories/PaymentMethodFactory.php public function definition(): array { return [ 'brand' => fake()->testCardBrand(), // "visa" 'number' => fake()->testCardNumber('visa'), // Luhn-valid PAN 'display' => fake()->testCardNumberFormatted('amex'), 'cvv' => fake()->testCardCvv('amex'), // 4 digits, correctly 'expires_at' => fake()->testCardExpiry(), // "09/29" ]; }
Available formatters: testCardNumber(), testCardNumberFormatted(),
testCardCvv(), testCardBrand(), testCardBrandName(), testCardExpiry(),
testCard() (the whole Card object), invalidCardNumber().
Why not Faker's own creditCardNumber()? It is fine as far as it goes —
Visa, Mastercard, Amex, Discover and JCB, all 16 digits except Amex's 15. This
provider exists for what is missing from that list: Diners Club at 14 digits,
Maestro and UnionPay at 19, Troy, a CVV sized per network rather than always
three digits, and a validator that agrees with the generator. The 19-digit
Maestro is the case that actually breaks form code.
Artisan commands
php artisan test-cards:generate # one random card php artisan test-cards:generate visa --count=10 php artisan test-cards:generate maestro --length=19 --format=csv php artisan test-cards:generate --seed=1337 --count=5 # reproducible php artisan test-cards:generate amex --format=json php artisan test-cards:validate "4111 1111 1111 1111" php artisan test-cards:validate 4111111111111112 --json # exits 1
--format takes table (default), json, csv or pan. The validate command
exits non-zero on a number that fails, so it drops into a shell pipeline.
Writing tests
Table-driven, with a fresh corpus each run. In Pest:
use CCGenerator\TestCards\TestCards; it('accepts every network we settle', function (string $pan) { $this->postJson('/checkout', ['card_number' => $pan]) ->assertSuccessful(); })->with(fn () => collect(TestCards::oneOfEach())->pluck('pan')->all()); it('rejects a mistyped digit', function () { $this->postJson('/checkout', ['card_number' => TestCards::invalid('visa')]) ->assertJsonValidationErrorFor('card_number'); });
In PHPUnit:
public static function cardNumbers(): array { return array_map( static fn ($card) => [$card->brand->value, $card->pan], TestCards::many(100), ); } #[DataProvider('cardNumbers')] public function testTheInputMaskSurvivesEveryNetwork(string $network, string $pan): void { $this->assertSame($pan, str_replace(' ', '', formatCardInput($pan))); }
One hundred fresh numbers per run costs nothing and finds the seams a single
hard-coded 4111 1111 1111 1111 never will — the 15-digit Amex, the 14-digit
Diners, the 19-digit Maestro.
When a failure has to be reproducible, seed it. Put this in phpunit.xml and
the whole application generates the same cards on every machine:
<php> <env name="TEST_CARDS_SEED" value="1337"/> </php>
Or per test, without touching the config:
$cards = CardService::seeded(1337); $cards->generate('visa')->pan; // the same number, forever
Symfony
Enable the bundle for the environments that need it. A test-data package generally has no business in prod:
// config/bundles.php return [ // ... CCGenerator\TestCards\Symfony\CCGeneratorTestCardsBundle::class => ['dev' => true, 'test' => true], ];
Configuration is optional:
# config/packages/ccgenerator_test_cards.yaml ccgenerator_test_cards: seed: ~ # an integer makes generated cards reproducible expiry_years_ahead: 5
# config/packages/test/ccgenerator_test_cards.yaml ccgenerator_test_cards: seed: 1337 # deterministic in the test environment only
Services and autowiring
Every service is registered under its FQCN, so type-hinting is all it takes:
use CCGenerator\TestCards\CardService; use CCGenerator\TestCards\CardValidator; final class CheckoutFixtures { public function __construct( private readonly CardService $cards, private readonly CardValidator $validator, ) { } public function aVisaCard(): string { return $this->cards->generate('visa')->pan; } }
Generator, CardValidator, BrandDetector and CardService are all
available, as is the RandomSource interface if you want to plug in your own
engine.
The TestCardNumber constraint
use CCGenerator\TestCards\Symfony\Validator\TestCardNumber; use Symfony\Component\Validator\Constraints as Assert; final class CheckoutInput { #[Assert\NotBlank] #[TestCardNumber(networks: ['visa', 'mastercard'])] public string $cardNumber = ''; // A stored number where the checksum is no longer meaningful #[TestCardNumber(requireLuhn: false)] public ?string $numberOnFile = null; }
The constraint stays quiet on empty input — that is NotBlank's job, and
composing the two is what lets an optional card field stay optional.
Violations carry a code, so a controller can branch on the reason without matching on English:
| Code constant | Meaning |
|---|---|
TestCardNumber::LUHN_FAILED_ERROR |
Checksum failed — usually one mistyped digit |
TestCardNumber::BAD_LENGTH_ERROR |
Right network, wrong digit count |
TestCardNumber::UNKNOWN_NETWORK_ERROR |
No known network issues on this IIN |
TestCardNumber::NETWORK_NOT_ALLOWED_ERROR |
A network this field does not accept |
TestCardNumber::NON_DIGIT_ERROR |
Something other than digits and separators |
Messages are overridable per usage, and each carries the placeholders you would expect:
#[TestCardNumber(
networks: ['visa'],
message: 'card.number.invalid', // a translation key works too
)]
Works the same in a form type:
$builder->add('cardNumber', TextType::class, [ 'constraints' => [new NotBlank(), new TestCardNumber(networks: ['visa'])], ]);
Console commands
bin/console test-cards:generate visa --count=10
bin/console test-cards:generate maestro --length=19 --format=csv
bin/console test-cards:generate --seed=1337 --count=5
bin/console test-cards:validate "4111 1111 1111 1111"
Both support tab completion for the network argument, and test-cards:validate
exits non-zero when the number fails.
In a functional test
use CCGenerator\TestCards\CardService; final class CheckoutControllerTest extends WebTestCase { public function testAcceptsEveryNetworkWeSettle(): void { $client = static::createClient(); $cards = static::getContainer()->get(CardService::class); foreach ($cards->oneOfEach() as $network => $card) { $client->request('POST', '/checkout', ['card_number' => $card->pan]); self::assertResponseIsSuccessful($network); } } }
Standalone CLI
For projects with no framework console:
vendor/bin/test-cards # one random card vendor/bin/test-cards visa --count=5 vendor/bin/test-cards maestro --length=19 --format=csv vendor/bin/test-cards --seed=1337 --count=3 --format=pan vendor/bin/test-cards validate "4111 1111 1111 1111" vendor/bin/test-cards --help
$ vendor/bin/test-cards amex --count=2
American Express 3425 625911 24835 cvv 5483 07/27
American Express 3741 736767 45063 cvv 8660 11/31
API reference
Generation
| Call | Returns |
|---|---|
TestCards::generate($brand = null, $length = null, $expiryYearsAhead = null) |
One Card. null picks a random network. |
TestCards::many(int $count, $brand = null, …) |
list<Card>, not deduplicated. |
TestCards::oneOfEach() |
array<string, Card> — one per network. |
TestCards::invalid($brand = null) |
A PAN with a deliberately broken check digit. |
TestCards::seeded(int $seed) |
A CardService with reproducible output. |
generate() throws UnknownNetworkException on an unknown network and
InvalidArgumentException on a length that network never issues —
generate('amex', length: 16) is a mistake worth failing loudly.
Card
$card->brand; // Brand::Visa (backed enum, ->value is "visa") $card->pan; // "4835385157667831" $card->cvv; // "204" $card->expiryMonth; // "09" $card->expiryYear; // "2029" $card->formatted(); // "4835 3851 5766 7831" — grouped per network $card->expiry(); // "09/29" $card->expiry('/', false); // "09/2029" $card->networkName(); // "Visa" $card->toArray(); // and it is JsonSerializable and Stringable
Validation
$result = TestCards::validate('4111 1111 1111 1111'); $result->valid; // bool — true only when every check passed $result->brand; // Brand|null $result->pan; // separators removed $result->length; // int $result->luhn; // bool $result->errors; // list<string>
Errors are stable string codes rather than sentences, so you can map them to
your own copy: empty, non_digit, unknown_network, bad_length,
luhn_failed, brand_not_allowed. The constants live on ValidationResult.
A number that passes Luhn but matches no known IIN range comes back as
unknown_network rather than being rejected outright. New ranges get allocated,
and any table like this one goes stale before the standard does.
Restrict to specific networks with the second argument:
TestCards::isValid($pan, ['visa', 'mastercard']); // bool
Detection and the checksum
TestCards::detectBrand('4'); // Brand::Visa — works on partial input TestCards::detectBrand('22'); // Brand::Mastercard TestCards::detectBrand(''); // null, not an exception TestCards::isLuhnValid('4111 1111 1111 1111'); // true TestCards::luhnCheckDigit('411111111111111'); // "1"
detectBrand() handles partial input on purpose, so it can drive a brand
indicator while someone is still typing.
The rule table
use CCGenerator\TestCards\Networks; Networks::get('amex')->cvvLength; // 4 Networks::get('amex')->lengths; // [15] Networks::get('maestro')->lengths; // [16, 19] Networks::all(); // array<string, Network>
Sizing a CVV field from cvvLength rather than hard-coding 3 is the one-line
version of this whole package.
Supported networks
Each name links to a browser version of the generator, for when you want a number without opening a REPL.
| Key | Network | Lengths | CVV |
|---|---|---|---|
visa |
Visa | 16 | 3 |
mastercard |
Mastercard | 16 | 3 |
amex |
American Express | 15 | 4 |
discover |
Discover | 16 | 3 |
jcb |
JCB | 16 | 3 |
diners |
Diners Club | 14 | 3 |
maestro |
Maestro | 16, 19 | 3 |
unionpay |
UnionPay | 16, 19 | 3 |
troy |
Troy | 16 | 3 |
Spellings are forgiving: amex, AMEX and american-express are the same
network, as are diners and diners_club.
Why the ranges are what they are
The rule table is where hand-rolled card fixtures usually go wrong, so the three most common mistakes are worth spelling out:
- Mastercard's 2-series (222100–272099) has been live since 2017. Code that only checks 51–55 rejects a real, in-issue Mastercard.
- Maestro is not "50, 56–69". That approximation swallows Discover's 6011, 65 and 644–649 and UnionPay's 62, so a generator built on it emits numbers that are not Maestro at all. This package uses the allocations Maestro actually issues on.
- Not every network is 16 digits. Amex is 15, classic Diners Club is 14, and Maestro and UnionPay issue at 19 as well as 16 — the full picture is in card number length by network. A form that hard-codes 16 truncates real cards.
The reasoning behind each field of the number is in card number structure and BIN/IIN explained.
Reproducible fixtures
Randomness is the default, because a corpus that is the same every run stops finding new seams. When a failure has to be reproducible, seed it:
$cards = CardService::seeded(1337); $cards->many(50); // the same 50 numbers, on every machine, forever
The engine is a self-contained xorshift32. It deliberately does not call
mt_srand(): seeding PHP's global generator from a library reaches outside the
package and changes the behaviour of unrelated code in the same process.
Unseeded generation draws from random_int(). That is not a security property —
nothing here protects anything — it is a correctness one: a biased generator
under-samples part of the range, which is precisely the gap a test corpus exists
to close. SeededRandom uses rejection sampling for the same reason.
A note on where you put the numbers
Synthetic PANs are still card-shaped, and card-shaped strings in logs, error trackers and analytics payloads are exactly what a PCI DSS audit flags. Treat them like the real thing in that one respect — PCI DSS for developers covers which parts of the standard apply to the code rather than the datacentre, and test data management for QA covers keeping fixtures out of places they should not reach.
Related tools
The same rule table runs in several places, for when you want the numbers without writing PHP:
- Browser generator — generate and copy test numbers for any of the nine networks, no install.
- Bulk generator — up to 10,000 cards with reproducible seeds, exported as CSV, JSON, JSONL, SQL or TSV, for fixtures that live outside PHP.
- Card validator — paste a
number, see the same structural checks
validate()runs, with the failure explained. - BIN lookup and BIN generator — the issuer side of the number.
- CVV generator — and why a CVV cannot be derived from a PAN.
- npm package — the same API for JavaScript and TypeScript (source).
- Chrome extension — generates test numbers, validates BINs and fills checkout forms in one click while you develop.
Background reading
Longer write-ups of the mechanics behind this package:
- The Luhn algorithm — worked example, reference implementation, and what it misses
- Luhn in four languages — the check digit routine in PHP, JavaScript, Python and Ruby, with shared test vectors
- Card brand detection regex — and why most of the ones circulating are wrong
- Payment form testing checklist — what to test in a card field beyond the happy path
- Why test cards fail on real payment systems
Contributing and security
Corrections to the IIN table are welcome — with a source; the bar is described in CONTRIBUTING.md. The package's security posture (no dependencies, no scripts, no network, no real card data) is spelled out in SECURITY.md.
License
MIT — © ccgenerator.org