Skip to content

Credit Card Validator — PHP source

Validate credit card numbers with the Luhn checksum and detect the issuing brand (Visa, Mastercard, Amex, Discover, Diners, JCB, UnionPay, Maestro). 100% client-side - nothing is transmitted.

This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.

<?php
/**
 * credit-card-validator — PHP.
 * CosmoDev polyglot showcase port of the credit-card-validator tool,
 * ported from src/lib/creditCard.ts.
 *
 * Display source — part of CosmoDev's polyglot tool pages.
 *
 * Pure credit-card validation logic with no I/O: sanitize to digits, run the
 * Luhn checksum, detect the issuing brand by prefix + length, and group the
 * number for display. Throws nothing; mirrors the TypeScript contract.
 *
 * PCRE note: without the /u modifier, \d and \D are ASCII [0-9] / [^0-9],
 * matching the JavaScript source exactly.
 */

declare(strict_types=1);

/**
 * Luhn checksum (mod-10). Empty or non-digit input returns false.
 */
function luhn_check(string $digits): bool
{
    if ($digits === '' || preg_match('/^\d*$/', $digits) !== 1) {
        return false;
    }
    $sum = 0;
    $shouldDouble = false;
    // Walk right-to-left; double every second digit, collapsing >9 to its digit-sum
    // (e.g. 7*2=14 -> 14-9=5, identical to summing 1+4).
    for ($i = strlen($digits) - 1; $i >= 0; $i--) {
        $d = ord($digits[$i]) - 48; // ord('0') === 48
        if ($shouldDouble) {
            $d *= 2;
            if ($d > 9) {
                $d -= 9;
            }
        }
        $sum += $d;
        $shouldDouble = !$shouldDouble;
    }
    return $sum % 10 === 0;
}

/**
 * Brand detection table. ORDER MATTERS — the first prefix+length match wins.
 * Maestro is last because its broad "6" / 5x ranges overlap narrower issuers.
 */
function credit_card_brand_rules(): array
{
    return [
        ['name' => 'Visa',            're' => '/^4/',                                                                          'lengths' => [13, 16, 19]],
        ['name' => 'Mastercard',      're' => '/^(5[1-5]|2(2[2-9]|[3-6]\d|7[01]|720))/',                                        'lengths' => [16]],
        ['name' => 'American Express','re' => '/^3[47]/',                                                                      'lengths' => [15]],
        ['name' => 'Discover',        're' => '/^(6011|65|64[4-9]|622(12[6-9]|1[3-9]\d|[2-8]\d\d|9([01]\d|2[0-5])))/',         'lengths' => [16, 19]],
        ['name' => 'Diners Club',     're' => '/^(30[0-5]|3095|36|38|39|54|55)/',                                               'lengths' => [14, 16]],
        ['name' => 'JCB',             're' => '/^35(2[89]|[3-8]\d)/',                                                          'lengths' => [16, 17, 18, 19]],
        ['name' => 'UnionPay',        're' => '/^62/',                                                                         'lengths' => [16, 17, 18, 19]],
        ['name' => 'Maestro',         're' => '/^(50|56|57|58|6)/',                                                            'lengths' => [12, 13, 14, 15, 16, 17, 18, 19]],
    ];
}

/**
 * Detect the card brand by prefix + length. Returns null when unknown.
 */
function detect_brand(string $digits): ?string
{
    if ($digits === '') {
        return null;
    }
    $len = strlen($digits);
    foreach (credit_card_brand_rules() as $rule) {
        if (preg_match($rule['re'], $digits) === 1 && in_array($len, $rule['lengths'], true)) {
            return $rule['name'];
        }
    }
    return null;
}

/**
 * Slice digits into runs of the given widths, joining with spaces.
 */
function credit_card_group(string $digits, array $widths): string
{
    $parts = [];
    $i = 0;
    $len = strlen($digits);
    foreach ($widths as $w) {
        if ($i >= $len) {
            break;
        }
        $parts[] = substr($digits, $i, $w);
        $i += $w;
    }
    // Any trailing remainder (e.g. a partially typed number) is appended as-is.
    if ($i < $len) {
        $parts[] = substr($digits, $i);
    }
    return implode(' ', $parts);
}

/**
 * Group the number per brand spacing rules.
 */
function format_card(string $digits, ?string $brand): string
{
    if ($digits === '') {
        return '';
    }
    // Amex uses a 4-6-5 grouping; Diners Club's 14-digit variant uses 4-6-4.
    if ($brand === 'American Express') {
        return credit_card_group($digits, [4, 6, 5]);
    }
    if ($brand === 'Diners Club' && strlen($digits) === 14) {
        return credit_card_group($digits, [4, 6, 4]);
    }
    // Default: groups of four separated by spaces; the lookahead (?=.) drops the trailing space.
    return trim(preg_replace('/(.{4})(?=.)/', '$1 ', $digits) ?? '');
}

/**
 * Validate a card number. Always returns an array shaped like CardInfo; never throws.
 */
function validate_card(string $input): array
{
    // preg_replace returns null only on engine error; treat that as empty.
    $sanitized = preg_replace('/\D+/', '', $input) ?? '';
    $brand = detect_brand($sanitized);
    // Luhn is only meaningful once we have a plausible card length (>= 12 digits).
    $luhnValid = strlen($sanitized) >= 12 && luhn_check($sanitized);
    return [
        'input'     => $input,
        'sanitized' => $sanitized,
        'valid'     => $luhnValid,
        'luhnValid' => $luhnValid,
        'brand'     => $brand,
        'formatted' => format_card($sanitized, $brand),
    ];
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →