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.

v1.0.0 2026-08-23 17:07 UTC

This package is auto-updated.

Last update: 2026-08-23 17:28:02 UTC


README

Packagist Version Tests PHP Version License: MIT Dependencies: none

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

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:

Background reading

Longer write-ups of the mechanics behind this package:

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