├── LICENSE ├── README.md ├── UPGRADING.md ├── composer.json └── src ├── Casts ├── E164PhoneNumberCast.php ├── PhoneNumberCast.php └── RawPhoneNumberCast.php ├── PhoneNumber.php ├── PhoneServiceProvider.php ├── Rules └── Phone.php └── helpers.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Propaganistas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Phone 2 | 3 | [![Tests](https://github.com/Propaganistas/Laravel-Phone/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/Propaganistas/Laravel-Phone/actions/workflows/tests.yml) 4 | [![Latest Stable Version](https://poser.pugx.org/propaganistas/laravel-phone/v/stable)](https://packagist.org/packages/propaganistas/laravel-phone) 5 | [![Total Downloads](https://poser.pugx.org/propaganistas/laravel-phone/downloads)](https://packagist.org/packages/propaganistas/laravel-phone) 6 | [![License](https://poser.pugx.org/propaganistas/laravel-phone/license)](https://packagist.org/packages/propaganistas/laravel-phone) 7 | 8 | Adds phone number functionality to Laravel based on 9 | the [PHP port](https://github.com/giggsey/libphonenumber-for-php-lite) 10 | of [libphonenumber by Google](https://github.com/googlei18n/libphonenumber). 11 | 12 | ## Table of Contents 13 | 14 | - [Demo](#demo) 15 | - [Installation](#installation) 16 | - [Validation](#validation) 17 | - [Attribute casting](#attribute-casting) 18 | - [Utility class](#utility-phonenumber-class) 19 | - [Formatting](#formatting) 20 | - [Number information](#number-information) 21 | - [Equality comparison](#equality-comparison) 22 | - [Database considerations](#database-considerations) 23 | 24 | ## Demo 25 | 26 | Check out the behavior of this package in the [demo](https://laravel-phone.herokuapp.com). 27 | 28 | ## Installation 29 | 30 | Run the following command to install the latest applicable version of the package: 31 | 32 | ```bash 33 | composer require propaganistas/laravel-phone 34 | ``` 35 | 36 | The Service Provider gets discovered automatically by Laravel. 37 | 38 | In your languages directory, add an extra translation in every `validation.php` language file: 39 | 40 | ```php 41 | 'phone' => 'The :attribute field must be a valid number.', 42 | ``` 43 | 44 | ## Validation 45 | 46 | Use the `phone` keyword in your validation rules array or use the `Propaganistas\LaravelPhone\Rules\Phone` rule class to 47 | define the rule in an expressive way. 48 | 49 | To put constraints on the allowed originating countries, you can explicitly specify the allowed country codes. 50 | 51 | ```php 52 | 'my_input' => 'phone:US,BE', 53 | // 'my_input' => (new Phone)->country(['US', 'BE']) 54 | ``` 55 | 56 | Or to make things more dynamic, you can also match against another data field holding a country code. For example, to 57 | require a phone number to match the provided country of residence. 58 | Make sure the country field has the same name as the phone field but with `_country` appended for automatic discovery, 59 | or provide your custom country field name as a parameter to the validator: 60 | 61 | ```php 62 | 'my_input' => 'phone', 63 | // 'my_input' => (new Phone) 64 | 'my_input_country' => 'required_with:my_input', 65 | ``` 66 | 67 | ```php 68 | 'my_input' => 'phone:custom_country_field', 69 | // 'my_input' => (new Phone)->countryField('custom_country_field') 70 | 'custom_country_field' => 'required_with:my_input', 71 | ``` 72 | 73 | Note: country codes should be [*ISO 3166-1 alpha-2 74 | compliant*](http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements). 75 | 76 | To support _any valid internationally formatted_ phone number next to the whitelisted countries, use the `INTERNATIONAL` 77 | parameter. This can be useful when you're expecting locally formatted numbers from a specific country but also want to 78 | accept any other foreign number entered properly: 79 | 80 | ```php 81 | 'my_input' => 'phone:INTERNATIONAL,BE', 82 | // 'my_input' => (new Phone)->international()->country('BE') 83 | ``` 84 | 85 | To specify constraints on the number type, append the allowed types to the parameters, e.g.: 86 | 87 | ```php 88 | 'my_input' => 'phone:mobile', 89 | // 'my_input' => (new Phone)->type('mobile') 90 | // 'my_input' => (new Phone)->type(libphonenumber\PhoneNumberType::MOBILE) 91 | ``` 92 | 93 | The most common types are `mobile` and `fixed_line`, but feel free to use any of the types 94 | defined [here](https://github.com/giggsey/libphonenumber-for-php-lite/blob/master/src/PhoneNumberType.php). 95 | 96 | Prepend a type with an exclamation mark to blacklist it instead. Note that you can never use whitelisted *and* 97 | blacklisted types at the same time. 98 | 99 | ```php 100 | 'my_input' => 'phone:!mobile', 101 | // 'my_input' => (new Phone)->notType('mobile') 102 | // 'my_input' => (new Phone)->notType(libphonenumber\PhoneNumberType::MOBILE) 103 | ``` 104 | 105 | You can also enable lenient validation by using the `LENIENT` parameter. 106 | With leniency enabled, only the length of a number is checked instead of actual carrier patterns. 107 | 108 | ```php 109 | 'my_input' => 'phone:LENIENT', 110 | // 'my_input' => (new Phone)->lenient() 111 | ``` 112 | 113 | ## Attribute casting 114 | 115 | Two cast classes are provided for automatic casting of Eloquent model attributes: 116 | 117 | ```php 118 | use Illuminate\Database\Eloquent\Model; 119 | use Propaganistas\LaravelPhone\Casts\RawPhoneNumberCast; 120 | use Propaganistas\LaravelPhone\Casts\E164PhoneNumberCast; 121 | 122 | class User extends Model 123 | { 124 | public $casts = [ 125 | 'phone_1' => RawPhoneNumberCast::class.':BE', 126 | 'phone_2' => E164PhoneNumberCast::class.':BE', 127 | ]; 128 | } 129 | ``` 130 | 131 | Both classes automatically cast the database value to a PhoneNumber object for further use in your application. 132 | 133 | ```php 134 | $user->phone // PhoneNumber object or null 135 | ``` 136 | 137 | When setting a value, they both accept a string value or a PhoneNumber object. 138 | The `RawPhoneNumberCast` mutates the database value to the raw input number, while the `E164PhoneNumberCast` writes a 139 | formatted E.164 phone number to the database. 140 | 141 | In case of `RawPhoneNumberCast`, the cast needs to be hinted about the phone country in order to properly parse the raw 142 | number into a phone object. 143 | In case of `E164PhoneNumberCast` and the value to be set is not already in some international format, the cast needs to 144 | be hinted about the phone country in order to properly mutate the value. 145 | 146 | Both classes accept cast parameters in the same way: 147 | 148 | 1. When a similar named attribute exists, but suffixed with `_country` (e.g. phone_country), the cast will detect and 149 | use it automatically. 150 | 2. Provide another attribute's name as a cast parameter 151 | 3. Provide one or several country codes as cast parameters 152 | 153 | ```php 154 | public $casts = [ 155 | 'phone_1' => RawPhoneNumberCast::class.':country_field', 156 | 'phone_2' => E164PhoneNumberCast::class.':BE', 157 | ]; 158 | ``` 159 | 160 | **Important note:** Both casts expect __valid__ phone numbers in order to smoothly convert from/to PhoneNumber objects. 161 | Please validate phone numbers before setting them on a model. Refer to the [validation documentation](#validation) to 162 | learn how to validate phone numbers. 163 | 164 | #### ⚠️ Attribute assignment and `E164PhoneNumberCast` 165 | 166 | Due to the nature of `E164PhoneNumberCast` a valid country attribute is expected if the number is not passed in 167 | international format. Since Laravel applies casts instantly when setting an attribute, be sure to set the country 168 | attribute _before_ setting the phone number attribute. Otherwise `E164PhoneNumberCast` will encounter an empty country 169 | value and throw an unexpected exception. 170 | 171 | ```php 172 | // Wrong 173 | $model->fill([ 174 | 'phone' => '012 34 56 78', 175 | 'phone_country' => 'BE', 176 | ]); 177 | 178 | // Correct 179 | $model->fill([ 180 | 'phone_country' => 'BE', 181 | 'phone' => '012 34 56 78', 182 | ]); 183 | 184 | // Wrong 185 | $model->phone = '012 34 56 78'; 186 | $model->phone_country = 'BE'; 187 | 188 | // Correct 189 | $model->phone_country = 'BE'; 190 | $model->phone = '012 34 56 78'; 191 | ``` 192 | 193 | ## Utility PhoneNumber class 194 | 195 | A phone number can be wrapped in the `Propaganistas\LaravelPhone\PhoneNumber` class to enhance it with useful utility 196 | methods. It's safe to directly reference these objects in views or when saving to the database as they will degrade 197 | gracefully to the E.164 format. 198 | 199 | ```php 200 | use Propaganistas\LaravelPhone\PhoneNumber; 201 | 202 | (string) new PhoneNumber('+3212/34.56.78'); // +3212345678 203 | (string) new PhoneNumber('012 34 56 78', 'BE'); // +3212345678 204 | ``` 205 | 206 | Alternatively you can use the `phone()` helper function. It returns a `Propaganistas\LaravelPhone\PhoneNumber` instance 207 | or the formatted string if `$format` was provided: 208 | 209 | ```php 210 | phone('+3212/34.56.78'); // PhoneNumber instance 211 | phone('012 34 56 78', 'BE'); // PhoneNumber instance 212 | phone('012 34 56 78', 'BE', $format); // string 213 | ``` 214 | 215 | ### Formatting 216 | 217 | A PhoneNumber can be formatted in various ways: 218 | 219 | ```php 220 | $phone = new PhoneNumber('012/34.56.78', 'BE'); 221 | 222 | $phone->format($format); // See libphonenumber\PhoneNumberFormat 223 | $phone->formatE164(); // +3212345678 224 | $phone->formatInternational(); // +32 12 34 56 78 225 | $phone->formatRFC3966(); // tel:+32-12-34-56-78 226 | $phone->formatNational(); // 012 34 56 78 227 | 228 | // Formats so the number can be called straight from the provided country. 229 | $phone->formatForCountry('BE'); // 012 34 56 78 230 | $phone->formatForCountry('NL'); // 00 32 12 34 56 78 231 | $phone->formatForCountry('US'); // 011 32 12 34 56 78 232 | 233 | // Formats so the number can be clicked on and called straight from the provided country using a cellphone. 234 | $phone->formatForMobileDialingInCountry('BE'); // 012345678 235 | $phone->formatForMobileDialingInCountry('NL'); // +3212345678 236 | $phone->formatForMobileDialingInCountry('US'); // +3212345678 237 | ``` 238 | 239 | ### Number information 240 | 241 | Get some information about the phone number: 242 | 243 | ```php 244 | $phone = new PhoneNumber('012 34 56 78', 'BE'); 245 | 246 | $phone->getType(); // libphonenumber\PhoneNumberType::FIXED_LINE 247 | $phone->isOfType('fixed_line'); // true (or use $phone->isOfType(libphonenumber\PhoneNumberType::FIXED_LINE) ) 248 | $phone->getCountry(); // 'BE' 249 | $phone->isOfCountry('BE'); // true 250 | ``` 251 | 252 | ### Equality comparison 253 | 254 | Check if a given phone number is (not) equal to another one: 255 | 256 | ```php 257 | $phone = new PhoneNumber('012 34 56 78', 'BE'); 258 | 259 | $phone->equals('012/34.56.76', 'BE') // true 260 | $phone->equals('+32 12 34 56 78') // true 261 | $phone->equals( $anotherPhoneObject ) // true/false 262 | 263 | $phone->notEquals('045 67 89 10', 'BE') // true 264 | $phone->notEquals('+32 45 67 89 10') // true 265 | $phone->notEquals( $anotherPhoneObject ) // true/false 266 | ``` 267 | 268 | ## Database considerations 269 | 270 | > Disclaimer: Phone number handling is quite different in each application. The topics mentioned below are therefore 271 | > meant as a set of thought starters; support will **not** be provided. 272 | 273 | Storing phone numbers in a database has always been a speculative topic and there's simply no silver bullet. It all 274 | depends on your application's requirements. Here are some things to take into account, along with an implementation 275 | suggestion. Your ideal database setup will probably be a combination of some of the pointers detailed below. 276 | 277 | ### Uniqueness 278 | 279 | The E.164 format globally and uniquely identifies a phone number across the world. It also inherently implies a specific 280 | country and can be supplied as-is to the `phone()` helper. 281 | 282 | You'll need: 283 | 284 | * One column to store the phone number 285 | * To format the phone number to E.164 before persisting it 286 | 287 | Example: 288 | 289 | * User input = `012/45.65.78` 290 | * Database column 291 | * `phone` (varchar) = `+3212456578` 292 | 293 | ### Presenting the phone number the way it was inputted 294 | 295 | If you store formatted phone numbers the raw user input will unretrievably get lost. It may be beneficial to present 296 | your users with their very own inputted phone number, for example in terms of improved user experience. 297 | 298 | You'll need: 299 | 300 | * Two columns to store the raw input and the correlated country 301 | 302 | Example: 303 | 304 | * User input = `012/34.56.78` 305 | * Database columns 306 | * `phone` (varchar) = `012/34.56.78` 307 | * `phone_country` (varchar) = `BE` 308 | 309 | ### Supporting searches 310 | 311 | Searching through phone numbers can quickly become ridiculously complex and will always require deep understanding of 312 | the context and extent of your application. Here's _a_ possible approach covering quite a lot of "natural" use cases. 313 | 314 | You'll need: 315 | 316 | * Three additional columns to store searchable variants of the phone number: 317 | * Normalized input (raw input with all non-alpha characters stripped) 318 | * National formatted phone number (with all non-alpha characters stripped) 319 | * E.164 formatted phone number 320 | * Probably a `saving()` observer (or equivalent) to prefill the variants before persistence 321 | * An extensive search query utilizing the searchable variants 322 | 323 | Example: 324 | 325 | * User input = `12/34.56.78` 326 | * Observer method: 327 | ```php 328 | public function saving(User $user) 329 | { 330 | if ($user->isDirty('phone') && $user->phone) { 331 | $user->phone_normalized = preg_replace('/[^0-9]/', '', $user->phone); 332 | $user->phone_national = preg_replace('/[^0-9]/', '', phone($user->phone, $user->phone_country)->formatNational()); 333 | $user->phone_e164 = phone($user->phone, $user->phone_country)->formatE164(); 334 | } 335 | } 336 | ``` 337 | * Database columns 338 | * `phone_normalized` (varchar) = `12345678` 339 | * `phone_national` (varchar) = `012345678` 340 | * `phone_e164` (varchar) = `+3212345678` 341 | * Search query: 342 | ```php 343 | // $search holds the search term 344 | User::where(function($query) use ($search) { 345 | $query->where('phone_normalized', 'LIKE', preg_replace('/[^0-9]/', '', $search) . '%') 346 | ->orWhere('phone_national', 'LIKE', preg_replace('/[^0-9]/', '', $search) . '%') 347 | ->orWhere('phone_e164', 'LIKE', preg_replace('/[^+0-9]/', '', $search) . '%') 348 | }); 349 | ``` 350 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrading 2 | 3 | - [From 5.x to 6.x](#from-5x-to-6x) 4 | - [From <5.3 to >=5.3](#from-53-to-53) 5 | - [From 4.x to 5.x](#from-4x-to-5x) 6 | 7 | ## From 5.x to 6.x 8 | 9 | `libphonenumber` [shifted](https://github.com/giggsey/libphonenumber-for-php-lite/releases/tag/9.0.0) from using class 10 | constants to native enums. Version 6.0.0 has feature-parity with the last version of 5.x, but the codebase 11 | has been reworked to harness the power of libphonenumber's enums. 12 | 13 | **Estimated time to upgrade: 0 to 15 minutes** 14 | 15 | ### PHP type hints 16 | 17 | Type hints have been added to parameters and return types to the codebase where applicable. While already working code 18 | should continue to operate properly, you might be warned by your IDE about mismatching types. 19 | 20 | ### Validation 21 | 22 | #### String-based validation rules 23 | 24 | If you made use of constants in *string-based* validation rules, you'll need to update your code. 25 | Treat the reference correctly as an enum or make use of other methods to reference the type. 26 | 27 | ```php 28 | 'phonefield' => 'phone:'.libphonenumber\PhoneNumberType::MOBILE, 29 | // becomes 30 | 'phonefield' => 'phone:'.libphonenumber\PhoneNumberType::MOBILE->value, 31 | 'phonefield' => 'phone:mobile', 32 | // or use the rule object 33 | ``` 34 | 35 | #### Object-based validation rules 36 | 37 | If you referenced a number's type in an object-based validation rule by its explicit integer value (e.g. when stored 38 | somewhere), you'll need to update your code. Cast or convert the value first to an enum or make use of other methods to 39 | validate 40 | on type. 41 | 42 | ```php 43 | 'phonefield' => (new Phone)->type(1), 44 | // becomes 45 | 'phonefield' => (new Phone)->type(libphonenumber\PhoneNumberType::from(1)), 46 | 'phonefield' => (new Phone)->type(libphonenumber\PhoneNumberType::MOBILE), 47 | // or use a string-based rule 48 | ``` 49 | 50 | #### Rename of shortcut method 51 | 52 | If you used the shortcut method to validate on fixed line phone numbers, you'll need to update the method's name. 53 | It has been converted to snake case to match the corresponding enum case more aptly. 54 | 55 | ```php 56 | (new Phone)->fixedLine(); 57 | // becomes 58 | (new Phone)->fixed_line(); 59 | ``` 60 | 61 | ### Utility PhoneNumber class 62 | 63 | #### Bubbling of `libphonenumber` exceptions 64 | 65 | The package's custom exceptions (e.g. `NumberParseException`, `CountryCodeException`, `NumberFormatException` and 66 | `IncompatibleTypesException`) have been removed. The package now bubbles `libphonenumber`'s exceptions, which is 67 | normally only narrowed to `libphonenumber\NumberParseException`. 68 | 69 | #### Object creation 70 | 71 | The signature of `PhoneNumber::__construct()` has changed. It now doesn't accept `null` anymore. 72 | 73 | | | Before | After | 74 | |--------------------|-------------|-------------------------| 75 | | Parameter $number | ?string | string | 76 | | Parameter $country | mixed | array \| string \| null | 77 | | Returns | PhoneNumber | PhoneNumber | 78 | 79 | #### PhoneNumberType: from constants to enum 80 | 81 | The signature of the `getType()` method has changed. It now returns an enum of `libphonenumber\PhoneNumberType` instead 82 | of a string. 83 | 84 | | | Before | After | 85 | |---------|--------|----------------------------------| 86 | | Returns | string | `libphonenumber\PhoneNumberType` | 87 | 88 | The signature of `isOfType($type)` has changed. If you used a string name or referenced libphonenumber's 89 | `PhoneNumberType` as a constant (e.g. `libphonenumber\PhoneNumberType::MOBILE`), 90 | you're already safe because constants and enums share the same syntax. Yay! 91 | If you're using a PhoneNumberType by its explicit integer value (e.g. when stored somewhere), you'll need to cast or 92 | convert it first. 93 | 94 | | | Before | After | 95 | |-----------------|---------------|--------------------------------------------------------------------------------| 96 | | Parameter $type | string \| int | string \| `libphonenumber\PhoneNumberType` | 97 | | Returns | bool | bool | 98 | | Throws | - | `InvalidArgumentException` when `$type` is a string and could not be converted | 99 | 100 | ### PhoneNumberFormat: from constants to enum 101 | 102 | The signature of `format($format)` has changed. If you used a string name or referenced libphonenumber's 103 | `PhoneNumberFormat` as a constant (e.g. `libphonenumber\PhoneNumberFormat::NATIONAL`), 104 | you're already safe because constants and enums share the same syntax. Yay! 105 | If you're using a PhoneNumberFormat by its explicit integer value (e.g. when stored somewhere), you'll need to cast or 106 | convert it first. 107 | 108 | | | Before | After | 109 | |-------------------|---------------|----------------------------------------------------------------------------------| 110 | | Parameter $format | string \| int | string \| `libphonenumber\PhoneNumberFormat` | 111 | | Returns | string | string | 112 | | Throws | - | `InvalidArgumentException` when `$format` is a string and could not be converted | 113 | 114 | ### Service container 115 | 116 | The package previously registered `libphonenumber` as a singleton in the service container. This was never used 117 | internally and as such the registration has been removed completely. 118 | If you relied on the service container to resolve `libphonenumber`, you'll need to add it back in a Service Provider: 119 | 120 | ```php 121 | $this->app->singleton('libphonenumber', function ($app) { 122 | return PhoneNumberUtil::getInstance(); 123 | }); 124 | ``` 125 | 126 | ## From <5.3 to >=5.3 127 | 128 | The internal dependency `giggsey/libphonenumber-for-php` is now substituted by `giggsey/libphonenumber-for-php-lite`. 129 | 130 | `libphonenumber-for-php-lite` is a lightweight drop-in replacement for `libphonenumber-for-php`, significantly reducing 131 | the package size being pulled in. `libphonenumber-for-php-lite` excludes geolocation, carrier information and short 132 | number info. 133 | 134 | This is a non-breaking change for functionality provided by `Laravel-Phone`. 135 | However, if you have defined a macro, please review your code and if needed require `giggsey/libphonenumber-for-php` as 136 | an explicit dependency in your project. 137 | 138 | ## From 4.x to 5.x 139 | 140 | The package now minimally requires PHP 8.0 and Laravel 9.0. It also supports Laravel 10. 141 | All documented behavior is preserved. There are just some minor syntactical changes that might need your attention. 142 | 143 | **Estimated time to upgrade: 0 to 5 minutes** 144 | 145 | ### Validation 146 | 147 | #### New feature 148 | 149 | - The `Phone` rule is now available to be referenced as 150 | a [rule object](https://laravel.com/docs/9.x/validation#using-rule-objects) ( 151 | `Propaganistas\LaravelPhone\Rules\Phone`): 152 | ```php 153 | 'phonefield' => (new Phone)->mobile()->country('BE') 154 | ``` 155 | 156 | #### Breaking changes 157 | 158 | - The `detect()` method of the Rule macro has been **renamed** to `international()` to better describe its behavior. 159 | ```php 160 | 'phonefield' => Rule::phone()->detect() 161 | // becomes 162 | 'phonefield' => Rule::phone()->international() 163 | ``` 164 | - The `AUTO` parameter has been **renamed** to `INTERNATIONAL` to better describe its behavior. 165 | ```php 166 | 'phonefield' => 'phone:AUTO' 167 | // becomes 168 | 'phonefield' => 'phone:INTERNATIONAL' 169 | ``` 170 | 171 | ### Utility PhoneNumber class 172 | 173 | #### Breaking changes 174 | 175 | - The `make()` method has been **removed** as it was redundant. Use the `phone()` helper or simply construct a new 176 | object. 177 | ```php 178 | PhoneNumber::make($number, $country) 179 | 180 | // becomes 181 | phone($number, $country) // 1-to-1 replacement ; chainable with subsequent methods 182 | // or 183 | new PhoneNumber($number, $country) // wrap in additional parentheses to chain with subsequent methods 184 | ``` 185 | - the `ofCountry()` method has been **removed**. Specification of possible countries is now only possible while 186 | constructing the object. 187 | ```php 188 | $object = new PhoneNumber($number); 189 | $object->ofCountry($country); 190 | 191 | // becomes 192 | $object = new PhoneNumber($number, $country); // or phone($number, $country) 193 | ``` 194 | - The **undocumented** public method `numberLooksInternational()` has been removed. There is no alternative. 195 | 196 | ### Attribute casting 197 | 198 | #### Breaking changes 199 | 200 | - Similar to the other cast, `RawPhoneNumberCast` will now also throw an exception when it gets invoked with an invalid 201 | phone object (i.e. while __accessing__ the casted attribute). Make sure to validate phone numbers before persisting 202 | them and provide the appropriate country code to the cast. 203 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "propaganistas/laravel-phone", 3 | "description": "Adds phone number functionality to Laravel based on Google's libphonenumber API.", 4 | "keywords": [ 5 | "laravel", 6 | "libphonenumber", 7 | "validation", 8 | "phone" 9 | ], 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Propaganistas", 14 | "email": "Propaganistas@users.noreply.github.com" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.2", 19 | "ext-mbstring": "*", 20 | "illuminate/contracts": "^11.0|^12.0", 21 | "illuminate/support": "^11.0|^12.0", 22 | "illuminate/validation": "^11.0|^12.0", 23 | "giggsey/libphonenumber-for-php-lite": "^9.0" 24 | }, 25 | "require-dev": { 26 | "orchestra/testbench": "*", 27 | "phpunit/phpunit": "^11.5.3", 28 | "laravel/pint": "^1.21" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "Propaganistas\\LaravelPhone\\": "src/" 33 | }, 34 | "files": [ 35 | "src/helpers.php" 36 | ] 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Propaganistas\\LaravelPhone\\Tests\\": "tests/" 41 | } 42 | }, 43 | "extra": { 44 | "laravel": { 45 | "providers": [ 46 | "Propaganistas\\LaravelPhone\\PhoneServiceProvider" 47 | ] 48 | } 49 | }, 50 | "minimum-stability": "stable", 51 | "prefer-stable": true 52 | } 53 | -------------------------------------------------------------------------------- /src/Casts/E164PhoneNumberCast.php: -------------------------------------------------------------------------------- 1 | getCountry() === null) { 23 | throw new UnexpectedValueException('Queried value for '.$key.' is not in international format'); 24 | } 25 | 26 | return $phone; 27 | } 28 | 29 | /** 30 | * Transform the attribute to its underlying model values. 31 | * 32 | * @param PhoneNumber|string|null $value 33 | */ 34 | public function set(Model $model, string $key, mixed $value, array $attributes): ?string 35 | { 36 | if (! $value) { 37 | return null; 38 | } 39 | 40 | if (! $value instanceof PhoneNumber) { 41 | $value = new PhoneNumber($value, 42 | $this->getPossibleCountries($key, $attributes) 43 | ); 44 | } 45 | 46 | return $value->formatE164(); 47 | } 48 | 49 | /** 50 | * Serialize the attribute when converting the model to an array. 51 | */ 52 | public function serialize(Model $model, string $key, mixed $value, array $attributes): ?string 53 | { 54 | if (! $value) { 55 | return null; 56 | } 57 | 58 | return $value->formatE164(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Casts/PhoneNumberCast.php: -------------------------------------------------------------------------------- 1 | parameters = func_get_args(); 17 | } 18 | 19 | protected function getPossibleCountries($key, array $attributes): array 20 | { 21 | $parameters = array_map(function ($parameter) use ($attributes) { 22 | if ($value = Arr::get($attributes, $parameter)) { 23 | return $value; 24 | } 25 | 26 | return $parameter; 27 | }, [...$this->parameters, $key.'_country']); 28 | 29 | return array_filter($parameters, function ($parameter) { 30 | return PhoneNumber::isValidCountry($parameter); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Casts/RawPhoneNumberCast.php: -------------------------------------------------------------------------------- 1 | getPossibleCountries($key, $attributes) 22 | ); 23 | 24 | $country = $phone->getCountry(); 25 | 26 | if ($country === null) { 27 | throw new InvalidArgumentException('Missing country specification for '.$key.' attribute cast'); 28 | } 29 | 30 | return new PhoneNumber($value, $country); 31 | } 32 | 33 | /** 34 | * Transform the attribute to its underlying model values. 35 | * 36 | * @param PhoneNumber|string|null $value 37 | */ 38 | public function set(Model $model, string $key, mixed $value, array $attributes): ?string 39 | { 40 | if ($value instanceof PhoneNumber) { 41 | return $value->getRawNumber(); 42 | } 43 | 44 | return (string) $value; 45 | } 46 | 47 | /** 48 | * Serialize the attribute when converting the model to an array. 49 | */ 50 | public function serialize(Model $model, string $key, mixed $value, array $attributes): ?string 51 | { 52 | if (! $value) { 53 | return null; 54 | } 55 | 56 | return $value->getRawNumber(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/PhoneNumber.php: -------------------------------------------------------------------------------- 1 | |string|null $country 27 | */ 28 | final public function __construct(string $number, array|string|null $country = null) 29 | { 30 | $this->number = $number; 31 | $this->countries = Arr::wrap($country); 32 | } 33 | 34 | public function getCountry(): ?string 35 | { 36 | // Try to detect the country first from the number itself. 37 | try { 38 | return PhoneNumberUtil::getInstance()->getRegionCodeForNumber( 39 | PhoneNumberUtil::getInstance()->parse($this->number) 40 | ); 41 | } catch (Throwable) { 42 | } 43 | 44 | // Only then iterate over the provided countries. 45 | $countries = array_filter($this->countries, function ($country) { 46 | return is_string($country) && static::isValidCountry($country); 47 | }); 48 | 49 | foreach (array_unique($countries) as $country) { 50 | try { 51 | $libPhoneObject = PhoneNumberUtil::getInstance()->parse($this->number, $country); 52 | } catch (Throwable) { 53 | continue; 54 | } 55 | 56 | if ($this->lenient) { 57 | if (PhoneNumberUtil::getInstance()->isPossibleNumber($libPhoneObject, $country)) { 58 | return mb_strtoupper($country); 59 | } 60 | 61 | continue; 62 | } 63 | 64 | if (PhoneNumberUtil::getInstance()->isValidNumberForRegion($libPhoneObject, $country)) { 65 | return PhoneNumberUtil::getInstance()->getRegionCodeForNumber($libPhoneObject); 66 | } 67 | } 68 | 69 | return null; 70 | } 71 | 72 | /** 73 | * @param string|array $country 74 | */ 75 | public function isOfCountry(array|string $country): bool 76 | { 77 | $instance = clone $this; 78 | $instance->countries = Arr::wrap($country); 79 | 80 | return in_array( 81 | mb_strtoupper($instance->getCountry()), 82 | array_map('mb_strtoupper', $instance->countries) 83 | ); 84 | } 85 | 86 | public static function isValidCountry(string $country): bool 87 | { 88 | $supported = PhoneNumberUtil::getInstance()->getSupportedRegions(); 89 | 90 | return in_array( 91 | mb_strtoupper($country), 92 | array_map('mb_strtoupper', $supported) 93 | ); 94 | } 95 | 96 | public function getType(): PhoneNumberType 97 | { 98 | return PhoneNumberUtil::getInstance()->getNumberType( 99 | $this->toLibPhoneObject() 100 | ); 101 | } 102 | 103 | /** 104 | * @param PhoneNumberType|string|array $type 105 | */ 106 | public function isOfType(PhoneNumberType|string|array $type): bool 107 | { 108 | $types = array_map(fn ($value) => static::normalizeType($value), Arr::wrap($type)); 109 | 110 | // Add the unsure type when applicable. 111 | if (in_array(PhoneNumberType::FIXED_LINE, $types) || in_array(PhoneNumberType::MOBILE, $types)) { 112 | $types[] = PhoneNumberType::FIXED_LINE_OR_MOBILE; 113 | } 114 | 115 | return in_array($this->getType(), $types, true); 116 | } 117 | 118 | /** @internal */ 119 | public static function normalizeType(PhoneNumberType|string $type): PhoneNumberType 120 | { 121 | if ($type instanceof PhoneNumberType) { 122 | return $type; 123 | } 124 | 125 | foreach (PhoneNumberType::cases() as $case) { 126 | if (mb_strtoupper($case->name) === mb_strtoupper($type)) { 127 | return $case; 128 | } 129 | } 130 | 131 | throw new InvalidArgumentException(sprintf('"%s" could not be matched to a valid PhoneNumberType', $type)); 132 | } 133 | 134 | public function format(PhoneNumberFormat|string $format): string 135 | { 136 | return PhoneNumberUtil::getInstance()->format( 137 | $this->toLibPhoneObject(), static::normalizeFormat($format) 138 | ); 139 | } 140 | 141 | public function formatInternational(): string 142 | { 143 | return $this->format(PhoneNumberFormat::INTERNATIONAL); 144 | } 145 | 146 | public function formatNational(): string 147 | { 148 | return $this->format(PhoneNumberFormat::NATIONAL); 149 | } 150 | 151 | public function formatE164(): string 152 | { 153 | return $this->format(PhoneNumberFormat::E164); 154 | } 155 | 156 | public function formatRFC3966(): string 157 | { 158 | return $this->format(PhoneNumberFormat::RFC3966); 159 | } 160 | 161 | /** @internal */ 162 | public static function normalizeFormat(PhoneNumberFormat|string $format): PhoneNumberFormat 163 | { 164 | if ($format instanceof PhoneNumberFormat) { 165 | return $format; 166 | } 167 | 168 | foreach (PhoneNumberFormat::cases() as $case) { 169 | if (mb_strtoupper($case->name) === mb_strtoupper($format)) { 170 | return $case; 171 | } 172 | } 173 | 174 | throw new InvalidArgumentException(sprintf('"%s" could not be matched to a valid PhoneNumberFormat', $format)); 175 | } 176 | 177 | public function formatForCountry(string $country): string 178 | { 179 | if (! static::isValidCountry($country)) { 180 | throw new InvalidArgumentException(sprintf('"%s" could not be matched to a valid country', $country)); 181 | } 182 | 183 | return PhoneNumberUtil::getInstance()->formatOutOfCountryCallingNumber( 184 | $this->toLibPhoneObject(), 185 | $country 186 | ); 187 | } 188 | 189 | public function formatForMobileDialingInCountry(string $country, bool $withFormatting = false): string 190 | { 191 | if (! static::isValidCountry($country)) { 192 | throw new InvalidArgumentException(sprintf('"%s" could not be matched to a valid country', $country)); 193 | } 194 | 195 | return PhoneNumberUtil::getInstance()->formatNumberForMobileDialing( 196 | $this->toLibPhoneObject(), 197 | $country, 198 | $withFormatting 199 | ); 200 | } 201 | 202 | public function isValid(): bool 203 | { 204 | try { 205 | if ($this->lenient) { 206 | return PhoneNumberUtil::getInstance()->isPossibleNumber( 207 | $this->toLibPhoneObject() 208 | ); 209 | } 210 | 211 | return PhoneNumberUtil::getInstance()->isValidNumberForRegion( 212 | $this->toLibPhoneObject(), 213 | $this->getCountry(), 214 | ); 215 | } catch (Throwable) { 216 | return false; 217 | } 218 | } 219 | 220 | public function lenient(bool $enable = true): self 221 | { 222 | $this->lenient = $enable; 223 | 224 | return $this; 225 | } 226 | 227 | /** 228 | * @param string|array|null $country 229 | */ 230 | public function equals(PhoneNumber|string $number, array|string|null $country = null): bool 231 | { 232 | try { 233 | if (! $number instanceof static) { 234 | $number = new static($number, $country); 235 | } 236 | 237 | return $this->formatE164() === $number->formatE164(); 238 | } catch (Throwable) { 239 | return false; 240 | } 241 | } 242 | 243 | /** 244 | * @param string|array|null $country 245 | */ 246 | public function notEquals(PhoneNumber|string $number, array|string|null $country = null): bool 247 | { 248 | return ! $this->equals($number, $country); 249 | } 250 | 251 | public function getRawNumber(): string 252 | { 253 | return $this->number; 254 | } 255 | 256 | /** 257 | * @throws \libphonenumber\NumberParseException 258 | */ 259 | public function toLibPhoneObject(): \libphonenumber\PhoneNumber 260 | { 261 | return PhoneNumberUtil::getInstance()->parse( 262 | $this->number, $this->getCountry() 263 | ); 264 | } 265 | 266 | /** 267 | * @param int $options 268 | * @return string 269 | **/ 270 | public function toJson($options = 0) 271 | { 272 | return json_encode($this->jsonSerialize(), $options); 273 | } 274 | 275 | public function jsonSerialize(): string 276 | { 277 | return $this->formatE164(); 278 | } 279 | 280 | public function __serialize() 281 | { 282 | return ['number' => $this->formatE164()]; 283 | } 284 | 285 | public function __unserialize(array $serialized): void 286 | { 287 | $this->number = $serialized['number']; 288 | } 289 | 290 | public function __toString(): string 291 | { 292 | // Formatting the phone number could throw an exception, but __toString() doesn't cope well with that. 293 | // Let's just return the original number in that case. 294 | try { 295 | return $this->formatE164(); 296 | } catch (Throwable) { 297 | return $this->number; 298 | } 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/PhoneServiceProvider.php: -------------------------------------------------------------------------------- 1 | callAfterResolving('validator', function (Factory $validator) { 16 | $validator->extendDependent('phone', function ($attribute, $value, array $parameters, $validator) { 17 | $rule = (new Phone) 18 | ->setData($validator->getData()) 19 | ->setParameters($parameters); 20 | 21 | return InvokableValidationRule::make($rule) 22 | ->setValidator($validator) 23 | ->passes($attribute, $value); 24 | }); 25 | }); 26 | 27 | Rule::macro('phone', function () { 28 | return new Rules\Phone; 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Rules/Phone.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected array $data; 20 | 21 | protected ?string $countryField = null; 22 | 23 | /** 24 | * @var array 25 | */ 26 | protected array $countries = []; 27 | 28 | /** 29 | * @var array 30 | */ 31 | protected array $allowedTypes = []; 32 | 33 | /** 34 | * @var array 35 | */ 36 | protected array $blockedTypes = []; 37 | 38 | protected bool $international = false; 39 | 40 | protected bool $lenient = false; 41 | 42 | public function setData(array $data) 43 | { 44 | $this->data = $data; 45 | 46 | return $this; 47 | } 48 | 49 | public function validate(string $attribute, mixed $value, Closure $fail): void 50 | { 51 | if (! $this->passes($attribute, $value)) { 52 | $fail('validation.phone')->translate(); 53 | } 54 | } 55 | 56 | protected function passes(string $attribute, mixed $value) 57 | { 58 | if (! empty($this->allowedTypes) && ! empty($this->blockedTypes)) { 59 | throw new LogicException('Cannot use "type" and "notType" simultaneously'); 60 | } 61 | 62 | $countries = array_filter([ 63 | $this->getCountryFieldValue($attribute), 64 | ...$this->countries, 65 | ]); 66 | 67 | try { 68 | $phone = (new PhoneNumber($value, $countries))->lenient($this->lenient); 69 | 70 | // Is the country within the allowed list (if applicable)? 71 | if (! $this->international && ! empty($countries) && ! $phone->isOfCountry($countries)) { 72 | return false; 73 | } 74 | 75 | // Is the type within the allowed list (if applicable)? 76 | if (! empty($this->allowedTypes) && ! $phone->isOfType($this->allowedTypes)) { 77 | return false; 78 | } 79 | 80 | // Is the type within the blocked list (if applicable)? 81 | if (! empty($this->blockedTypes) && $phone->isOfType($this->blockedTypes)) { 82 | return false; 83 | } 84 | 85 | return $phone->isValid(); 86 | } catch (Throwable) { 87 | return false; 88 | } 89 | } 90 | 91 | /** 92 | * @param array|string $country 93 | */ 94 | public function country(array|string $country): self 95 | { 96 | $countries = is_array($country) ? $country : func_get_args(); 97 | 98 | $this->countries = array_merge($this->countries, $countries); 99 | 100 | return $this; 101 | } 102 | 103 | public function countryField(string $name): self 104 | { 105 | $this->countryField = $name; 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * @param PhoneNumberType|string|array $type 112 | */ 113 | public function type(PhoneNumberType|string|array $type): self 114 | { 115 | $types = is_array($type) ? $type : func_get_args(); 116 | 117 | $this->allowedTypes = array_merge($this->allowedTypes, $types); 118 | 119 | return $this; 120 | } 121 | 122 | /** 123 | * @param PhoneNumberType|string|array $type 124 | */ 125 | public function notType(PhoneNumberType|string|array $type): self 126 | { 127 | $types = is_array($type) ? $type : func_get_args(); 128 | 129 | $this->blockedTypes = array_merge($this->blockedTypes, $types); 130 | 131 | return $this; 132 | } 133 | 134 | public function mobile(): self 135 | { 136 | $this->type(PhoneNumberType::MOBILE); 137 | 138 | return $this; 139 | } 140 | 141 | public function fixed_line(): self 142 | { 143 | $this->type(PhoneNumberType::FIXED_LINE); 144 | 145 | return $this; 146 | } 147 | 148 | public function lenient(): self 149 | { 150 | $this->lenient = true; 151 | 152 | return $this; 153 | } 154 | 155 | public function international(): self 156 | { 157 | $this->international = true; 158 | 159 | return $this; 160 | } 161 | 162 | protected function getCountryFieldValue(string $attribute): ?string 163 | { 164 | // Using Arr::get() enables support for nested data. 165 | return Arr::get($this->data, $this->countryField ?: $attribute.'_country'); 166 | } 167 | 168 | protected function isDataKey($attribute): bool 169 | { 170 | // Using Arr::has() enables support for nested data. 171 | return Arr::has($this->data, $attribute); 172 | } 173 | 174 | public function setParameters($parameters) 175 | { 176 | $parameters = is_array($parameters) ? $parameters : func_get_args(); 177 | 178 | foreach ($parameters as $parameter) { 179 | if (str_starts_with($parameter, '!') && $this->isTypeName($notParameter = substr($parameter, 1))) { 180 | $this->notType($notParameter); 181 | } elseif (strcasecmp('lenient', $parameter) === 0) { 182 | $this->lenient(); 183 | } elseif (strcasecmp('international', $parameter) === 0) { 184 | $this->international(); 185 | } elseif ($this->isTypeName($parameter)) { 186 | $this->type($parameter); 187 | } elseif ($this->isDataKey($parameter)) { 188 | $this->countryField = $parameter; 189 | } elseif (PhoneNumber::isValidCountry($parameter)) { 190 | $this->country($parameter); 191 | } 192 | } 193 | 194 | return $this; 195 | } 196 | 197 | protected function isTypeName(string $name): bool 198 | { 199 | try { 200 | PhoneNumber::normalizeType($name); 201 | 202 | return true; 203 | } catch (Throwable) { 204 | } 205 | 206 | return false; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | |string|null $country 9 | * @return ($format is null ? \Propaganistas\LaravelPhone\PhoneNumber : string) 10 | */ 11 | function phone(string $number, array|string|null $country = null, PhoneNumberFormat|string|null $format = null): PhoneNumber|string 12 | { 13 | $phone = new PhoneNumber($number, $country); 14 | 15 | return is_null($format) ? $phone : $phone->format($format); 16 | } 17 | } 18 | --------------------------------------------------------------------------------