定制 mmae/phones 二次开发

按需修改功能、优化性能、对接业务系统,提供一站式技术支持

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

mmae/phones

Composer 安装命令:

composer require mmae/phones

包简介

Validate and normalize phone numbers for 209 countries, with drop-in Laravel validation rules (exists/unique, translations, nullable).

README 文档

README

Laravel package for validating and normalizing phone numbers, with a matching set of validation rules.

  • 209 countries — one {CODE}Phone class and one {CODE}PhoneRule per entry in config/phones.php.
  • Normalizes any accepted shape (local 0, 00, +, bare dialing code) to a single canonical form.
  • Drop-in Laravel ValidationRules with exists/unique database checks, nullable/empty handling, custom messages, translations, and a full-control callback.

1. Supported Countries

209 countries, each with the full feature set. Every entry in config/phones.php ships all four of:

  • Phone — a {CODE}Phone class (MMAE\Phones\Phones\) for validation/normalization (§3, §5).
  • Rule — a {CODE}PhoneRule validation rule (MMAE\Phones\Rules\, §4).
  • Placeholder — a {CODE}Placeholder for UI hints/masks (MMAE\Phones\Placeholders\, §7).
  • DetectorCountryDetector support (§6), enabled whenever the config entry has a key and pattern.

Legend: ✅ supported · — not generated.

All 209 bundled countries are ✅ across every column. The columns matter once you extend the config yourself: a country you add to config/phones.php gets Detector support immediately (it reads config directly), but its {CODE}Phone / {CODE}PhoneRule / {CODE}Placeholder classes only exist after you add them (§8 → Adding a new country). Run php artisan config:show phones to list your live codes.

Full support matrix (209 countries)
Country Code Phone Rule Placeholder Detector
Andorra AD
United Arab Emirates AE
Afghanistan AF
Antigua & Barbuda AG
Anguilla AI
Albania AL
Armenia AM
Angola AO
Argentina AR
American Samoa AS
Austria AT
Australia AU
Azerbaijan AZ
Bosnia & Herzegovina BA
Barbados BB
Bangladesh BD
Belgium BE
Burkina Faso BF
Bulgaria BG
Bahrain BH
Burundi BI
Benin BJ
Bermuda BM
Brunei BN
Bolivia BO
Brazil BR
Bahamas BS
Bhutan BT
Botswana BW
Belarus BY
Belize BZ
Canada CA
Congo - Kinshasa CD
Central African Republic CF
Congo - Brazzaville CG
Switzerland CH
Côte d’Ivoire CI
Chile CL
Cameroon CM
China CN
Colombia CO
Costa Rica CR
Cuba CU
Cape Verde CV
Cyprus CY
Czechia CZ
Germany DE
Djibouti DJ
Denmark DK
Dominica DM
Dominican Republic DO
Algeria DZ
Ecuador EC
Estonia EE
Egypt EG
Eritrea ER
Spain ES
Ethiopia ET
Finland FI
Fiji FJ
Faroe Islands FO
France FR
Gabon GA
United Kingdom GB
Grenada GD
Georgia GE
Ghana GH
Gibraltar GI
Greenland GL
Gambia GM
Guinea GN
Equatorial Guinea GQ
Greece GR
Guatemala GT
Guam GU
Guinea-Bissau GW
Guyana GY
Hong Kong SAR China HK
Honduras HN
Croatia HR
Haiti HT
Hungary HU
Indonesia ID
Ireland IE
Israel IL
India IN
Iraq IQ
Iran IR
Iceland IS
Italy IT
Jamaica JM
Jordan JO
Japan JP
Kenya KE
Kyrgyzstan KG
Cambodia KH
Comoros KM
St. Kitts & Nevis KN
North Korea KP
South Korea KR
Kuwait KW
Cayman Islands KY
Kazakhstan KZ
Laos LA
Lebanon LB
St. Lucia LC
Liechtenstein LI
Sri Lanka LK
Liberia LR
Lesotho LS
Lithuania LT
Luxembourg LU
Latvia LV
Libya LY
Morocco MA
Monaco MC
Moldova MD
Montenegro ME
Madagascar MG
North Macedonia MK
Mali ML
Myanmar (Burma) MM
Mongolia MN
Macao SAR China MO
Northern Mariana Islands MP
Mauritania MR
Montserrat MS
Malta MT
Mauritius MU
Maldives MV
Malawi MW
Mexico MX
Malaysia MY
Mozambique MZ
Namibia NA
New Caledonia NC
Niger NE
Nigeria NG
Nicaragua NI
Netherlands NL
Norway NO
Nepal NP
New Zealand NZ
Oman OM
Panama PA
Peru PE
French Polynesia PF
Papua New Guinea PG
Philippines PH
Pakistan PK
Poland PL
Puerto Rico PR
Palestinian Territories PS
Portugal PT
Paraguay PY
Qatar QA
Romania RO
Serbia RS
Russia RU
Rwanda RW
Saudi Arabia SA
Solomon Islands SB
Seychelles SC
Sudan SD
Sweden SE
Singapore SG
Slovenia SI
Slovakia SK
Sierra Leone SL
San Marino SM
Senegal SN
Somalia SO
Suriname SR
South Sudan SS
São Tomé & Príncipe ST
El Salvador SV
Sint Maarten SX
Syria SY
Eswatini SZ
Turks & Caicos Islands TC
Chad TD
Togo TG
Thailand TH
Tajikistan TJ
Timor-Leste TL
Turkmenistan TM
Tunisia TN
Tonga TO
Türkiye TR
Trinidad & Tobago TT
Taiwan TW
Tanzania TZ
Ukraine UA
Uganda UG
United States US
Uruguay UY
Uzbekistan UZ
St. Vincent & Grenadines VC
Venezuela VE
British Virgin Islands VG
U.S. Virgin Islands VI
Vietnam VN
Vanuatu VU
Samoa WS
Kosovo XK
Yemen YE
South Africa ZA
Zambia ZM
Zimbabwe ZW

2. Installation

composer require mmae/phones

Config and translations are merged/loaded automatically — no publish needed to start.

3. Usage

Pick the entry point by what you have:

  • a known country → the {CODE}Phone class for normalization + a {CODE}PhoneRule for validation (below),
  • a country that varies per request → the generic Phone / PhoneRule (below),
  • an international number of unknown country → the detector (§6),
  • a full form or Livewire UI → the validation rules (§4) and Livewire examples (§9).

Validate a known-country number before saving

Prefer the rule (§4) in validate(); use the phone class to normalize before storing:

<?php

use Illuminate\Http\Request;
use MMAE\Phones\Phones\EGPhone;
use MMAE\Phones\Rules\EGPhoneRule;

class UserController extends \App\Http\Controllers\Controller
{
    public function store(Request $request)
    {
        $data = $request->validate([
            'name' => 'required',
            // format check, plus optional exists()/unique() — see §4
            'phone' => ['required', EGPhoneRule::make()],
        ]);

        // normalize any accepted shape (local 0, 00, +, bare) to one canonical form
        $data['phone'] = EGPhone::make($data['phone'])->toString();
        \App\Models\User::create($data);

        return back()->with('success', 'created');
    }
}

Each country ships a class under MMAE\Phones\Phones\ (EGPhone, SAPhone, LYPhone, AEPhone, …) and a matching rule under MMAE\Phones\Rules\ (EGPhoneRule, …) — one of each per config entry. Without the rule you can still validate by hand: if (EGPhone::make($data['phone'])->isNotValid()) { … }.

Validate when the country varies per user (multi-country registration)

Use the generic Phone / PhoneRule with an explicit $countryCode (a key in config/phones.php) instead of hardcoding one:

use MMAE\Phones\Phone;

$phone = Phone::make($user->phone, $user->country_code);
if ($phone->isNotValid()) {
    throw new \Exception('wrong format');
}

// withPlus() flips the +-prefix flag; stringify to send the +CC… form
$SMSService->message('hello')->to($phone->withPlus()->toString())->send();

For a form where the country is itself a field, drop PhoneRule::make($countryCode) into your rules — see §4 and the Livewire form in §9.

4. Validation Rules

Every country also ships a {CODE}PhoneRule under MMAE\Phones\Rules\, implementing Laravel's ValidationRule. Use it directly in $request->validate() / Validator::make().

use MMAE\Phones\Rules\EGPhoneRule;

$request->validate([
    'phone' => ['required', EGPhoneRule::make()],
]);

The country locale is locked on a {CODE}PhoneRule — it can't be swapped at runtime (that's the point of a per-country class). When the country varies per request, use the generic PhoneRule, which takes an explicit code:

use MMAE\Phones\Rules\PhoneRule;

$request->validate([
    'phone' => [PhoneRule::make($user->country_code)],
]);

Fluent modifiers

All rules share the same chainable API (from MMAE\Phones\Base\BasePhoneRule):

Method Purpose
make() fluent constructor (generic PhoneRule::make($code) takes the code)
message(string $message) override the invalid-format message (literal or translation key)
nullable(bool = true) pass and skip every check when the value is null
allowEmpty(bool = true) pass and skip every check when the value is ''
required() undo nullable()/allowEmpty() so null/empty are rejected
absent() mirror of required() — allow both null and empty to skip
exists(string $table, ?string $column = null, ?string $message = null) require the number to already exist in a table
unique(string $table, ?string $column = null, mixed $ignore = null, ?string $ignoreColumn = null, ?string $message = null) require the number to be unique in a table
validateUsing(Closure $callback) take full control of the flow (see below)

Existence & uniqueness

exists() / unique() match against every accepted shape of the number ($phone->all()), so a value stored in any form (local, international, +, 00) is found regardless of the shape submitted.

// number must already exist (any stored shape)
EGPhoneRule::make()->exists('users');

// number must be unique
EGPhoneRule::make()->unique('users');

// on update, ignore the current row
EGPhoneRule::make()->unique('users', ignore: $user->id);

// custom column and message
EGPhoneRule::make()->exists('contacts', 'mobile', 'No such contact.');

$column defaults to phone, $ignoreColumn to id, and $message to the package translation key. The format check always runs first — the database is never queried for an invalid number.

Null & empty

EGPhoneRule::make()->nullable();     // null → pass
EGPhoneRule::make()->allowEmpty();   // '' → pass
EGPhoneRule::make()->required();     // re-reject null/empty

Note: Laravel's Validator already skips non-implicit rules on empty strings before the rule runs, so allowEmpty() is only observable when the rule is driven directly. A present null does reach the rule and fails with the phones::validation.required message unless nullable() is set.

Full-control callback

validateUsing() replaces the entire built-in flow. The callback receives the resolved phone, the attribute, the raw value, a RuleConfig, and the $fail closure — you decide which checks to enforce and report your own errors.

use MMAE\Phones\Base\BasePhone;
use MMAE\Phones\Configs\RuleConfig;

EGPhoneRule::make()->exists('users')->validateUsing(
    function (BasePhone $phone, string $attribute, mixed $value, RuleConfig $config, Closure $fail) {
        if ($phone->isNotValid()) {
            $fail(trans($config->format->message));

            return;
        }

        if ($config->exists->enabled && ! MyLookup::has($phone->all())) {
            $fail(trans($config->exists->message));
        }
    }
);

RuleConfig is a readonly DTO exposing format, nullable, allowEmpty, exists, and unique sub-configs. Messages in the config are raw translation keys — wrap them in trans() when you fail.

A non-string, non-int, non-null value (e.g. an array) throws a RuntimeException rather than failing validation.

Translations

Rule messages resolve from the phones translation namespace (lang/{locale}/validation.php), keyed by phone, required, exists, unique. English and Arabic ship with the package, and messages follow app()->getLocale().

Publish to add locales or override wording:

php artisan vendor:publish --tag=mmae::phones-lang

This copies the files into lang/vendor/phones/{locale}/validation.php.

5. Phone API

All phone classes extend MMAE\Phones\Base\BasePhone, which implements Stringable and Arrayable.

Method Returns Purpose
isValid() bool number matches the country's regex
isNotValid() bool inverse of isValid()
toString() / (string) $phone string normalized key+provider+digits, '' if invalid
all() array every accepted key-prefixed variant (0, 00, +, bare)
segments() array named regex capture groups (key, provider, digits)
format(string $format) string render the parsed parts with braced tokens, '' if invalid
withPlus() static switch toString()/all() output to use + prefix (static flag)
withoutPlus() static switch back to no + prefix (default)
number() string the original, unmodified input
toArray() array alias for all()
config(?string $key = null) mixed read the country's config array, or one key from it

withPlus() / withoutPlus() mutate a static flag shared by the class, not per-instance state — set it right before casting to string.

Custom formatting

format() renders the parsed parts with braced tokens — {key} (dialing code), {local} (national trunk prefix), {provider}, {digits}. Any other text is emitted literally, so labels and separators pass through untouched; there is no escaping to worry about. Returns '' for an invalid number.

$phone = EGPhone::make('01012345678');

$phone->format('+{key} {provider}-{digits}');   // +20 10-12345678
$phone->format('{local}{provider}{digits}');    // 01012345678  (national form)
$phone->format('({key}) {provider} {digits}');  // (20) 10 12345678
$phone->format('tel: {key}{provider}{digits}'); // tel: 201012345678  (literal text kept)

EGPhone::make('01099')->format('{key} {provider}'); // '' (invalid)

6. Country detection (bulk imports)

When numbers arrive in international form only — a dialing code and no ISO country attached (a spreadsheet of +2010…, +9665…, +15551234567) — MMAE\Phones\CountryDetector resolves which configured country each one belongs to. That lets you validate and normalize a huge list without knowing the country up front.

use MMAE\Phones\CountryDetector;

CountryDetector::detect('+201000000000');   // ['EG']
CountryDetector::detect('00201000000000');  // ['EG']  — 00 / bare code / spaces & dashes all accepted
CountryDetector::detect('+15551234567');    // ['US', 'CA', ...] — every country sharing +1 (NANP)
CountryDetector::detect('01000000000');     // []      — local form, no dialing code → nothing to detect
CountryDetector::detect('+99900000');       // []      — no country has this code + length

CountryDetector::detectFirst('+201000000000'); // 'EG'  — first match, or null when none

Detection is international-only by design: a local/trunk-0 number carries no country, so it returns [] — for those you already know the country and should use Phone / {CODE}Phone directly. A dialing code can be shared (every NANP territory is +1), so detect() returns every matching code in config order and lets you decide; detectFirst() takes the first.

Speed

Built for bulk. Instead of scanning ~200 countries per call, the detector jumps straight to the candidates whose numbers are exactly the input's length, then walks a dialing-code trie one digit at a time — an impossible length is rejected before any work. The index is loaded once and cached in memory, so the hot path is a single array probe with no config() resolve per call.

Two further optimizations keep the hot path lean:

  • Shared dialing codes resolve by lookup, not by regex-per-country. A code used by many countries (every NANP territory is +1) would otherwise run one preg_match per country. For territories whose provider prefix is a fixed literal (area codes like 787, 868, 242), the baked index instead stores a provider → countries map, so a single hash lookup on the leading digits replaces ~20 regex tests. Non-literal patterns (+1's \d{3} wildcard, character classes) stay as regex. This cut +1 detection ~3× with identical results.
  • The hot path trusts the baked index shape. detect() does not re-validate the type of every node it reads on each call — it assumes config/phone-lookup.php is well-formed and only keeps the control-flow checks that end the walk. Dropping the per-call is_array/is_int/is_string guards shaved a further ~10–15%. See the warning under Precompiling the index for what that assumption costs you.

Measured on PHP 8.4 with the precompiled index, over a real 50/50 mix of valid and rejected numbers spread across every configured country — the exact CSV phones:dataset produces:

Metric detect() detectFirst()
per number ~1.8 µs ~1.5 µs
throughput ~570,000 / s ~650,000 / s
1,000,000 numbers ~1.8 s ~1.5 s

A million-row import therefore spends under ~2 s in detection — the database write dominates, not the validation. (Numbers are hardware-specific; reproduce them with the harness below.)

Reproducing it

The benchmark is dataset-driven so the timing reflects a production import, not a synthetic best case. A dev-only harness in the package workbench (phones:benchmark, shipped with the workbench, not the package) times the shipped detect() and detectFirst() as they run in production — these are the reference numbers above. Generate a real dataset with phones:dataset, then feed it in with --file:

php vendor/bin/testbench phones:dataset 1000000 --out=bench.csv   # 50/50 mix, all countries
php vendor/bin/testbench phones:benchmark --file=bench.csv        # time detection over it

Both the CSV reader and the inline generator stream in chunks (a generator yields one --chunk of rows at a time, default 50,000), so memory stays flat for any file size — and since parsing happens outside the timed closures, the numbers measure detection alone.

The length-keyed index is what keeps this flat: an input whose length no country uses misses the bucket and returns [] before a single dialing digit is walked, so rejects cost far less than matches and a half-invalid import stays cheap. Each run appends to phone-benchmarks.jsonl in the workbench storage so past numbers survive for comparison. Skip the CSV and let the command generate the mix inline — same factory, same country spread, tune the ratio with --valid:

php vendor/bin/testbench phones:benchmark 1000000 --valid=50   # half valid, random & interleaved across every country
php vendor/bin/testbench phones:benchmark 1000000 --valid=0    # all invalid — the pure rejection path

Precompiling the index

The package ships config/phone-lookup.php — the ready-to-walk index, baked from the bundled schema — and the service provider merges it into config('phone-lookup') at boot, so detect() works the instant you composer require mmae/phones, with no build step. (The index is a build artifact shipped with the package; there is no install-time hook because a dependency cannot run scripts in the host app — shipping it baked is what makes it instant.)

After you publish and extend config/phones.php, regenerate the index so detection sees the new codes:

php artisan phones:build-lookup

Rebuild automatically on composer install/update

Only relevant once you publish and edit config/phones.php — the shipped index already covers the bundled countries. To never forget the rebuild, add the command to your application's composer.json (a package can't inject this for you):

"scripts": {
    "post-autoload-dump": [
        "@php artisan phones:build-lookup --ansi"
    ]
}

Now every composer install, composer update, or composer dump-autoload recompiles config/phone-lookup.php from your current config/phones.php.

The baked index is required — there is no runtime fallback. The package ships one built from its bundled schema, so detection works out of the box; you only regenerate after changing config/phones.php. If you mutate config at runtime (e.g. in tests), call CountryDetector::flush() to force a reload.

⚠️ config/phone-lookup.php must exist and match config/phones.php. Never hand-edit it; regenerate it after any change.

For speed, detect() trusts the exact shape this file is generated with — it walks the index without validating each node's type per call (see the Speed note), and there is no runtime fallback to rebuild it from config('phones'). That is a deliberate trade: it buys ~10–15% on the hot path, but it means the compiled index is a hard dependency.

The contract:

  • Only produce this file with php artisan phones:build-lookup. Treat it as a build artifact, not source — never edit it by hand.
  • Regenerate it whenever you add, remove, or change a country in config/phones.php. A stale index silently misses your new countries — that's on you, not the package.
  • A missing index throws, loudly: the first detect() raises a RuntimeException telling you to run phones:build-lookup (checked once, off the hot path). A malformed or hand-edited index is undefined behavior and can TypeError mid-detection instead — so don't hand-edit it.

Recipe: import owners with key-only numbers

A realistic import: many owners, each with several phone numbers in international form only, validated before anything is saved. Detect the country from the key, validate/normalize with Phone, and collect the failures per row.

use Illuminate\Support\Facades\DB;
use MMAE\Phones\CountryDetector;
use MMAE\Phones\Phone;

$valid = [];
$failed = [];

// $rows: iterable<array{name: string, phones: list<string>}> — stream & chunk a
// huge file so memory stays flat; nothing is written until validation has run.
foreach ($rows as $owner) {
    $normalized = [];

    foreach ($owner['phones'] as $raw) {
        $code = CountryDetector::detectFirst($raw);          // country from the dialing key
        $phone = $code ? Phone::make($raw, $code) : null;

        if ($phone === null || $phone->isNotValid()) {
            $failed[] = ['name' => $owner['name'], 'phone' => $raw];

            continue;
        }

        $normalized[] = $phone->withPlus()->toString();      // canonical +CC form
    }

    if ($normalized !== []) {
        $valid[] = ['name' => $owner['name'], 'phones' => $normalized];
    }
}

DB::transaction(function () use ($valid) {
    foreach ($valid as $owner) {
        // Owner::create([...]) + attach $owner['phones']
    }
});

report_invalid($failed);   // surface the rejects instead of silently dropping them

detectFirst() recovers the country from the dialing key; Phone::make($raw, $code) then confirms the whole number is valid for that country and casts it to a single canonical form. Because detection is ~1.4 µs, the validation pass over a million numbers is ~1.5 s — safe to run inline in a chunked import job before the bulk insert. This flow is exercised end-to-end in src/tests/Feature/BulkImportOwnersTest.php.

detect() can return several codes for a shared dialing code (+1). If your import must land on exactly one country, disambiguate with other row data (e.g. a country column) rather than blindly taking detectFirst().

7. Placeholders

Every country also ships a {CODE}Placeholder under MMAE\Phones\Placeholders\, mirroring the phone classes. A placeholder describes the shape of a valid number — accepted provider prefixes and subscriber length — derived from the same config/phones.php schema. Use it to render input hints, example numbers, or format masks in the UI, and to build the :format shown in validation errors.

use MMAE\Phones\Placeholders\EGPlaceholder;

$data = EGPlaceholder::make()->extract();   // MMAE\Phones\Configs\PlaceholderData

$data->localFormat();          // '01[0,1,2,5]XXXXXXXX'  — every provider, local form
$data->internationalFormat();  // '+201[0,1,2,5]XXXXXXXX'
$data->local();                // '010XXXXXXXX'           — canonical (first) provider
$data->international();         // '+2010XXXXXXXX'
$data->providers;              // ['10', '11', '12', '15']
$data->digitsMin;              // 8

The mask character defaults to X; pass another to make() / the constructor:

EGPlaceholder::make('#')->extract()->localFormat();   // '01[0,1,2,5]########'

When the country varies at runtime, use the generic Placeholder, which takes an explicit code (mirroring Phone):

use MMAE\Phones\Placeholders\Placeholder;

Placeholder::make($user->country_code)->extract()->internationalFormat();

PlaceholderData API

extract() returns a readonly MMAE\Phones\Configs\PlaceholderData (also Arrayable via toArray()):

Member Returns Purpose
code, key, localKey string ISO code, dialing code, national trunk prefix
providers array every accepted provider prefix, e.g. ['10','11','12','15']
digitsMin / digitsMax int subscriber-part length range
provider() string canonical (first) provider prefix
digitsMask() string masked subscriber part, e.g. XXXXXXXX
bare() / local() / international(bool $plus = true) string one example number (canonical provider) in each shape
bareFormat() / localFormat() / internationalFormat(bool $plus = true) string format mask covering every provider
providerMask() string all providers collapsed into one token, e.g. 1[0,1,2,5]
examples() array {provider, bare, local, international} for every provider
toArray() array the whole thing as a nested array

8. Configuration

Config lives in config/phones.php, keyed by country code:

'EG' => [
    'code' => 'EG',
    'key' => '20',                                  // dialing code
    'local_key' => '0',                             // national trunk prefix
    'pattern' => '(?<provider>1[0125])(?<digits>\d{8})',
],

The full validation regex is assembled in BasePhone from key + local_key (the key capture group) plus pattern (the provider/digits groups) — you only supply the body via pattern.

It's merged automatically (mergeConfigFrom), no publish required. To customize (e.g. tighten a regex), publish and edit:

php artisan vendor:publish --tag=mmae::phones

Adding a new country

  1. Add an entry to config/phones.php with code, key, local_key, and pattern (must define provider/digits named groups; the key group is derived automatically).
  2. Add a {CC}Phone class mirroring EGPhone.php:
<?php

namespace MMAE\Phones\Phones;

use MMAE\Phones\Base\BasePhone;

final class JOPhone extends BasePhone
{
    public function __construct(string $number)
    {
        parent::__construct($number, 'JO');
    }

    public static function make(string $number): JOPhone
    {
        return new self($number);
    }
}
  1. Add a matching {CC}PhoneRule under src/Rules/:
<?php

declare(strict_types=1);

namespace MMAE\Phones\Rules;

use MMAE\Phones\Base\BasePhoneRule;

final class JOPhoneRule extends BasePhoneRule
{
    protected string $countryCode = 'JO';
}
  1. Add a matching {CC}Placeholder under src/Placeholders/ (mirroring EGPlaceholder.php), locking the same code.
  2. Refresh IDE autocomplete for the new code — see IDE autocomplete.

9. Livewire examples

Two ready-to-adapt Livewire single-file components, distilled from the pages this package develops against.

User form — validate, normalize & de-duplicate

A PhoneRule (§4) validates the input, Rule::in(array_keys(config('phones'))) accepts any configured country, and the value is normalized to the canonical + form (§5) before saving. ->unique('users', 'phone', $id) matches every stored shape, so a duplicate is caught no matter how it was saved (ignoring the current row on edit).

<?php
use Illuminate\Validation\Rule;
use Livewire\Component;
use MMAE\Phones\Phone;
use MMAE\Phones\Rules\PhoneRule;
use App\Models\User;

new class extends Component {
    public ?int $editingId = null;
    public string $country_code = 'EG';
    public string $phone = '';

    protected function rules(): array
    {
        return [
            'country_code' => ['required', Rule::in(array_keys(config('phones')))],
            'phone' => [
                PhoneRule::make($this->country_code)
                    ->unique('users', 'phone', $this->editingId),
            ],
        ];
    }

    public function save(): void
    {
        $this->validate();

        User::updateOrCreate(
            ['id' => $this->editingId],
            [
                'country_code' => $this->country_code,
                // store the canonical international form, e.g. +201012345678
                'phone' => Phone::make($this->phone, $this->country_code)->withPlus()->toString(),
            ],
        );
    }
};
?>

<form wire:submit="save">
    <select wire:model="country_code">
        @foreach (array_keys(config('phones')) as $code)
            <option value="{{ $code }}">{{ $code }}</option>
        @endforeach
    </select>

    <input type="text" wire:model="phone" placeholder="01012345678">
    @error('phone') <p>{{ $message }}</p> @enderror

    <button type="submit">Save</button>
</form>

Show each stored number's country back in a list with the detector (§6):

{{ \MMAE\Phones\CountryDetector::detectFirst($user->phone) ?? '' }}

The rule reports the country's expected format live as the user types:

User form with a live PhoneRule validation error

Country detector + placeholder card

Paste a full international number → detect its country (§6) → render that country's full PlaceholderData schema (§7). One dialing key can match several countries (every NANP country is +1), so all matches are listed and the user picks which one drives the info.

<?php
use Livewire\Attributes\Computed;
use Livewire\Component;
use MMAE\Phones\Configs\PlaceholderData;
use MMAE\Phones\CountryDetector;
use MMAE\Phones\Phone;
use MMAE\Phones\Placeholders\Placeholder;

new class extends Component {
    public string $number = '';
    public ?string $selected = null;

    // reset the pick whenever the number changes so it can't stick to a stale country
    public function updatedNumber(): void
    {
        $this->selected = null;
    }

    /** @return list<string> every country sharing this dialing key */
    #[Computed]
    public function detected(): array
    {
        return $this->number !== '' ? CountryDetector::detect(trim($this->number)) : [];
    }

    /** the user's pick if still valid, else the first match */
    #[Computed]
    public function country(): ?string
    {
        return in_array($this->selected, $this->detected, true)
            ? $this->selected
            : ($this->detected[0] ?? null);
    }

    #[Computed]
    public function info(): ?PlaceholderData
    {
        return $this->country ? Placeholder::make($this->country)->extract() : null;
    }

    #[Computed]
    public function valid(): ?bool
    {
        return $this->country
            ? Phone::make(trim($this->number), $this->country)->isValid()
            : null;
    }
};
?>

<div>
    <input type="text" wire:model.live.debounce.300ms="number" placeholder="+17875550123">

    @if ($this->detected)
        {{-- several countries share the key (+1): click a code to switch --}}
        @foreach ($this->detected as $code)
            <button type="button" wire:click="$set('selected', '{{ $code }}')"
                @class(['font-bold' => $code === $this->country])>{{ $code }}</button>
        @endforeach

        @php($info = $this->info)
        <dl>
            <dt>Dialing key</dt> <dd>+{{ $info->key }}</dd>
            <dt>Trunk key</dt>   <dd>{{ $info->localKey ?: '—' }}</dd>
            <dt>Providers</dt>   <dd>{{ implode(', ', $info->providers) }}</dd>
            <dt>Digits</dt>      <dd>{{ $info->digitsMin }}–{{ $info->digitsMax }}</dd>
            <dt>Format</dt>      <dd>{{ $info->internationalFormat() }}</dd>
            <dt>Example</dt>     <dd>{{ $info->international() }}</dd>
        </dl>

        <p>{{ $this->valid ? "Valid {$this->country} number" : "Fails {$this->country} format" }}</p>
    @elseif ($number !== '')
        <p>No country detected — enter the number in international form (with dialing key).</p>
    @endif
</div>

CountryDetector::detect('+17875550123') returns ['US', 'CA', 'PR'] — three countries share +1. A local-form number (01012345678, no dialing key) returns []; there you already know the country, so use Phone / PhoneRule with an explicit code instead.

A single-match number resolves straight to its country and full placeholder schema (dialing key, providers, digit lengths, masked formats, example, and every accepted shape):

Detector card for an Egyptian number

A number on a shared key (+17875550123US, CA, PR) lists all matches — click a code to drive the card. The same digits describe a different schema per country (note how PR narrows the providers to 787 / 939):

Detector card with +1 matches, US selected

Same number with CA selected

Same number with PR selected, providers 787/939

10. IDE autocomplete (PhpStorm only)

PhpStorm only. This feature relies on .phpstorm.meta.php, which only PhpStorm reads. Other editors (VS Code/Intelephense, Neovim, …) have no mechanism for suggesting string-literal argument values, so they get nothing here — but every code still validates fine at runtime.

The package ships a .phpstorm.meta.php registering all built-in codes as suggestions for the $countryCode argument of Phone::make(), PhoneRule::make(), and Placeholder::make() — typing Phone::make($n, '') pops the country list in PhpStorm.

It also suggests the braced format() tokens ({key}, {local}, {provider}, {digits}) plus a few common patterns for BasePhone::format(), so typing $phone->format('') pops the token vocabulary.

That shipped file is frozen at the built-in list. After you publish and extend config/phones.php, regenerate the metadata from your live config:

php artisan phones:ide-helper

This writes .phpstorm.meta.php/phones.php in your app root, listing every currently configured code. It goes in a .phpstorm.meta.php/ directory (which PhpStorm merges), so it never clobbers an existing root .phpstorm.meta.php. Pass --path= to write elsewhere. Re-run it whenever you change the config.

The IDE never reads config/phones.php itself — this command runs in your app, where config('phones') already includes your custom codes, and writes them into a static file the IDE does read. So the flow is: edit config → re-run command → IDE updated.

Editor-only: an unregistered code still validates fine at runtime — Phone::make() reads config('phones') directly. The command only keeps autocomplete in sync.

11. Testing

Package tests live in src/tests (Testbench, not top-level tests/).

composer test    # vendor/bin/pest --parallel
composer lint    # vendor/bin/phpstan analyse

12. Laravel Boost (AI assistants)

The package ships Laravel Boost guidance under resources/boost/, so AI coding assistants get package-specific, accurate help automatically. Nothing to configure — Boost auto-discovers it from the installed package.

composer require mmae/phones     # in any Boost-enabled project

Two pieces are exposed:

  • Guideline (resources/boost/guidelines/core.blade.php) — always-on. A short summary of the four tools ({CODE}Phone / {CODE}PhoneRule / {CODE}Placeholder / CountryDetector), how to pick an entry point, and the validate-then-normalize rule.
  • Skill (resources/boost/skills/phones-development/) — loaded on demand when the assistant touches phone code. SKILL.md gives the overview and gotchas; references/phones-guide.md is the full usage guide — every API, use cases, Eloquent / FormRequest / API / Livewire recipes, and how to test and validate results.

Boost finds them by scanning resources/boost/{guidelines,skills} in each required package; the folder ships in the Composer dist (not export-ignored). Verify discovery in a host app:

php artisan boost:install     # writes guidelines; lists discovered skills

13. Support

When reporting a bug, include the country code, the input number, and the expected vs. actual result.

14. License

MIT — see LICENSE.

15. Changelog

See CHANGELOG.md for release notes.

mmae/phones 适用场景与选型建议

mmae/phones 是一款 基于 PHP 开发的 Composer 扩展包,目前已累计 16 次下载、GitHub Stars 达 1, 最近一次更新时间为 2024 年 07 月 12 日, 在 PHP 生态内属于活跃度较高的组件。

我们在过去多个企业项目中使用过 mmae/phones 或与其功能相近的方案,如果你在选型或落地过程中遇到问题,例如 版本兼容、二次改造、私有化封装、与内部系统对接、生产 BUG 排查,欢迎联系我们协助评估。

围绕 mmae/phones 我们能提供哪些服务?
定制开发 / 二次开发

基于 mmae/phones 在你已有业务上做功能扩展、字段裁剪、UI 适配、与内部账号 / 权限 / 日志系统的深度对接。

BUG 修复 & 性能优化

线上偶发问题、内存泄漏、慢查询、并发异常等排查修复;针对高流量场景做缓存、队列、索引层面的调优。

项目外包 & 长期维护

承接完整的项目从需求 → 设计 → 开发 → 上线 → 长期运维;也可按月提供技术保姆服务。

yvsm@zunyunkeji.com QQ:316430983 微信:yvsm316 西安尊云信息科技 · 专注 PHP / Go / 分布式系统研发

统计信息

  • 总下载量: 16
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 2
  • 点击次数: 12
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 1
  • Watchers: 1
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2024-07-12