Filters & filter_var()

PHP · Lesson 22 of 50 9 min read Updated

PHP has a built-in filter extension for checking and cleaning data from outside your program: form fields, query strings, cookies, API payloads. One function, filter_var(), can check that a value is a whole number in a range, a well-formed email address, a URL, an IP address or a yes/no answer, and it converts the value to the right type while doing so.

Form Validation & Escaping showed the basic use of filter_var() and filter_input(). This lesson is the full tour: the difference between validating and sanitizing, how flags and options work, the URL trap that catches many developers, your own rules with FILTER_CALLBACK, and checking a whole form in one call.

Validate or sanitize?

The extension has two families of filters, and they do very different jobs:

Validate filtersSanitize filters
NamesFILTER_VALIDATE_INT, FILTER_VALIDATE_EMAIL...FILTER_SANITIZE_NUMBER_INT, FILTER_SANITIZE_EMAIL...
Question they answer"Is this value acceptable?""What is left after removing bad characters?"
Result for bad inputfalse (or null with a flag)A changed string, never false for a string
Type of resultConverted: int, float, bool or stringAlways a string

The safe habit is: validate input, escape output. Validation rejects bad data and tells the user. Sanitizing quietly changes what the user typed, which can turn nonsense into something that looks valid. Use sanitize filters only when you truly want to strip characters, for example removing spaces and dashes from a phone number before you validate it.

Flags and options

filter_var() takes up to three arguments: the value, the filter, and either a flag (a constant that switches on an extra behaviour) or an array with 'options' and 'flags' keys. Options are settings with values, such as a minimum and maximum:

PHP
<?php
declare(strict_types=1);

// Range checks with options
$age = ['options' => ['min_range' => 18, 'max_range' => 60]];
var_dump(filter_var('25', FILTER_VALIDATE_INT, $age));
var_dump(filter_var('16', FILTER_VALIDATE_INT, $age));

// A default instead of false
var_dump(filter_var('abc', FILTER_VALIDATE_INT, ['options' => ['default' => 1]]));

// Whitespace is fine, leading zeros are not
var_dump(filter_var(' 42 ', FILTER_VALIDATE_INT));
var_dump(filter_var('042', FILTER_VALIDATE_INT));

// A flag on its own: accept hexadecimal
var_dump(filter_var('0x1A', FILTER_VALIDATE_INT, FILTER_FLAG_ALLOW_HEX));

// Floats: thousands separators and a custom decimal mark
var_dump(filter_var('1,299.50', FILTER_VALIDATE_FLOAT));
var_dump(filter_var('1,299.50', FILTER_VALIDATE_FLOAT, FILTER_FLAG_ALLOW_THOUSAND));
var_dump(filter_var('12,5', FILTER_VALIDATE_FLOAT, ['options' => ['decimal' => ',']]));
Output
int(25)
bool(false)
int(1)
int(42)
bool(false)
int(26)
bool(false)
float(1299.5)
float(12.5)

Flags can be combined with |, as in FILTER_FLAG_ALLOW_HEX | FILTER_FLAG_ALLOW_OCTAL. Inside an options array, write them under the 'flags' key. Notice that '042' is rejected: FILTER_VALIDATE_INT does not allow a leading zero, so check PIN codes and phone numbers as text with a pattern, not as integers.

Booleans: three possible answers

Checkboxes and settings arrive as text like 'on', 'yes' or '1'. FILTER_VALIDATE_BOOL (a shorter name for FILTER_VALIDATE_BOOLEAN, added in PHP 8.0) understands them. Without a flag it returns false for anything it does not recognise, which hides typos. With FILTER_NULL_ON_FAILURE, unknown values become null:

PHP
<?php
declare(strict_types=1);

foreach (['yes', 'On', '1', 'true', 'no', 'off', '0', '', 'maybe'] as $raw) {
    $plain = filter_var($raw, FILTER_VALIDATE_BOOL);
    $strict = filter_var($raw, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
    printf("%-7s plain: %-5s with NULL_ON_FAILURE: %s\n", "'$raw'", var_export($plain, true), var_export($strict, true));
}
Output
'yes'   plain: true  with NULL_ON_FAILURE: true
'On'    plain: true  with NULL_ON_FAILURE: true
'1'     plain: true  with NULL_ON_FAILURE: true
'true'  plain: true  with NULL_ON_FAILURE: true
'no'    plain: false with NULL_ON_FAILURE: false
'off'   plain: false with NULL_ON_FAILURE: false
'0'     plain: false with NULL_ON_FAILURE: false
''      plain: false with NULL_ON_FAILURE: false
'maybe' plain: false with NULL_ON_FAILURE: NULL

Matching ignores upper and lower case, and the empty string counts as "no", which suits an unticked checkbox.

Emails, URLs and other formats

FILTER_VALIDATE_EMAIL checks the format of an address only; the mailbox may not exist. FILTER_VALIDATE_URL checks the general shape of a URL, and that is a trap: it accepts any scheme, including ftp: and javascript:. If you print a user's "website" as a link, a javascript: URL can run code in your visitors' browsers. Always check the scheme yourself:

PHP
<?php
declare(strict_types=1);

function safeWebsite(string $raw): ?string
{
    $url = filter_var(trim($raw), FILTER_VALIDATE_URL);
    if ($url === false) {
        return null;
    }
    $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
    return in_array($scheme, ['http', 'https'], true) ? $url : null;
}

$tests = ['https://meera-sweets.in/menu', 'meera-sweets.in', 'ftp://files.example.in/a.zip', 'javascript://x%0Aalert(1)'];
foreach ($tests as $raw) {
    $raw_ok = filter_var($raw, FILTER_VALIDATE_URL) !== false ? 'valid' : 'invalid';
    printf("%-30s filter: %-8s safe: %s\n", $raw, $raw_ok, safeWebsite($raw) ?? 'rejected');
}
Output
https://meera-sweets.in/menu   filter: valid    safe: https://meera-sweets.in/menu
meera-sweets.in                filter: invalid  safe: rejected
ftp://files.example.in/a.zip   filter: valid    safe: rejected
javascript://x%0Aalert(1)      filter: valid    safe: rejected

A URL without a scheme, such as meera-sweets.in, is invalid. You can ask for more with FILTER_FLAG_PATH_REQUIRED or FILTER_FLAG_QUERY_REQUIRED. Other useful validators:

  • FILTER_VALIDATE_IP checks IPv4 and IPv6 addresses. Add FILTER_FLAG_IPV4 or FILTER_FLAG_IPV6 to allow only one kind, FILTER_FLAG_NO_PRIV_RANGE to reject private addresses such as 192.168.1.10, or FILTER_FLAG_GLOBAL_RANGE (PHP 8.2) to allow only public addresses.
  • FILTER_VALIDATE_DOMAIN with FILTER_FLAG_HOSTNAME checks a host name such as shop-1.example.in.
  • FILTER_VALIDATE_REGEXP checks a value against your own pattern, given as the 'regexp' option. For a six-digit Indian PIN code that cannot start with 0: ['options' => ['regexp' => '/^[1-9][0-9]{5}$/']]. Patterns are explained in Regular Expressions.

Sanitizing filters

Sanitize filters remove or encode characters. They never say "invalid", so look carefully at what they return:

PHP
<?php
declare(strict_types=1);

var_dump(filter_var('+91 98765-43210', FILTER_SANITIZE_NUMBER_INT));
var_dump(filter_var('₹1,299.50', FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION));
var_dump(filter_var('12abc34', FILTER_SANITIZE_NUMBER_INT));
var_dump(filter_var(' meera (at) example.in ', FILTER_SANITIZE_EMAIL));
var_dump(filter_var('<b>Rohan & Sons</b>', FILTER_SANITIZE_FULL_SPECIAL_CHARS));
var_dump(filter_var("Line1\x07Bell", FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW));
Output
string(14) "+9198765-43210"
string(7) "1299.50"
string(4) "1234"
string(17) "meeraatexample.in"
string(35) "&lt;b&gt;Rohan &amp; Sons&lt;/b&gt;"
string(9) "Line1Bell"

Look at the results. FILTER_SANITIZE_NUMBER_INT keeps plus and minus signs, so the phone number is still not clean. '12abc34' silently became '1234', a number the user never typed. The "email" is now a string that is not an email at all. Sanitizing is not validation: if you sanitize, validate the result afterwards.

FILTER_SANITIZE_FULL_SPECIAL_CHARS encodes HTML like htmlspecialchars() does, but do this when you print a value, not when you store it. FILTER_UNSAFE_RAW (also called FILTER_DEFAULT) does nothing by default; with FILTER_FLAG_STRIP_LOW it removes invisible control characters such as the bell character \x07 above.

Your own rules with FILTER_CALLBACK

When no built-in filter fits, pass your own function with FILTER_CALLBACK. The callable goes in 'options', receives the value as a string, and whatever it returns is the result. If you give it an array, it runs on every item:

PHP
<?php
declare(strict_types=1);

function normaliseMobile(string $raw): string|false
{
    $digits = preg_replace('/\D/', '', $raw);      // keep digits only
    $digits = match (true) {
        strlen($digits) === 12 && str_starts_with($digits, '91') => substr($digits, 2),
        strlen($digits) === 11 && str_starts_with($digits, '0') => substr($digits, 1),
        default => $digits,
    };
    return preg_match('/^[6-9][0-9]{9}$/', $digits) ? $digits : false;
}

foreach (['+91 98765-43210', '098765 43210', '9876543210', '12345'] as $raw) {
    $result = filter_var($raw, FILTER_CALLBACK, ['options' => 'normaliseMobile']);
    printf("%-16s => %s\n", $raw, var_export($result, true));
}

$tags = filter_var([' php ', 'MySQL', ' laravel'], FILTER_CALLBACK, [
    'options' => fn(string $t): string => strtolower(trim($t)),
]);
print_r($tags);
Output
+91 98765-43210  => '9876543210'
098765 43210     => '9876543210'
9876543210       => '9876543210'
12345            => false
Array
(
    [0] => php
    [1] => mysql
    [2] => laravel
)

The mobile number check first removes everything except digits, then drops a 91 country code or a leading 0, and finally insists on ten digits starting with 6, 7, 8 or 9. Any callable works here, including the closures from Closures & Callables.

Checking a whole form at once

filter_var_array() checks many fields in one call. You describe each field with a filter, or with an array holding 'filter', 'options' and 'flags'. Its sister filter_input_array(INPUT_POST, $rules) reads straight from the request. Fields that are missing come back as null, and fields you did not list are dropped, which protects you from extra fields such as a sneaky is_admin:

PHP
<?php
declare(strict_types=1);

// Pretend this came from $_POST
$post = [
    'name' => '  Kabir Mehta ',
    'email' => 'kabir@example.in',
    'age' => '17',
    'newsletter' => 'on',
    'website' => 'htps:/kabir',
    'courses' => ['3', '8', 'x'],
    'is_admin' => '1',            // a field we never asked for
];

$rules = [
    'name' => ['filter' => FILTER_CALLBACK, 'options' => 'trim'],
    'email' => FILTER_VALIDATE_EMAIL,
    'age' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 18, 'max_range' => 60]],
    'newsletter' => ['filter' => FILTER_VALIDATE_BOOL, 'flags' => FILTER_NULL_ON_FAILURE],
    'website' => FILTER_VALIDATE_URL,
    'courses' => ['filter' => FILTER_VALIDATE_INT, 'flags' => FILTER_REQUIRE_ARRAY],
    'city' => FILTER_DEFAULT,
];

$data = filter_var_array($post, $rules);

foreach ($data as $field => $value) {
    printf("%-10s %s\n", $field, json_encode($value));
}
Output
name       "Kabir Mehta"
email      "kabir@example.in"
age        false
newsletter true
website    false
courses    [3,8,false]
city       null

Now each field has three possible states: a clean value, false (sent but invalid) or null (not sent). FILTER_REQUIRE_ARRAY says the field must be an array, such as checkboxes named courses[], and checks every item. Its opposite, FILTER_REQUIRE_SCALAR, rejects arrays where you expect one value, and FILTER_FORCE_ARRAY wraps a single value in an array. From here, building error messages is a simple loop: for each false or null, add a message for that field.

Common mistakes

  • Checking the result with !. if (!$age) also rejects a valid 0 or false. Compare with === false and === null.
  • Trusting FILTER_VALIDATE_URL for links. It accepts javascript: and ftp:. Check the scheme too.
  • Sanitizing instead of validating. FILTER_SANITIZE_NUMBER_INT turns '12abc34' into '1234'. Validate, and show an error.
  • Validating PIN codes or phone numbers as integers. Leading zeros are rejected and large numbers lose meaning. Treat them as text with a pattern.
  • Encoding HTML when saving. Store the clean value; escape with htmlspecialchars() when you print it.

Key points

  • Validate filters return the converted value or false; sanitize filters always return a changed string.
  • The third argument is a flag, or an array with 'options' (such as min_range, default, regexp) and 'flags'.
  • FILTER_VALIDATE_BOOL with FILTER_NULL_ON_FAILURE separates "no" from "not understood".
  • FILTER_VALIDATE_URL accepts any scheme; allow only http and https for links.
  • FILTER_CALLBACK runs your own function; filter_var_array() and filter_input_array() check whole forms and drop unknown fields.
  • FILTER_SANITIZE_STRING is deprecated; PHP 8.5 adds FILTER_THROW_ON_FAILURE.

Exercise

A delivery form sends pin, qty, express and email. Write $rules for filter_var_array() so that pin must match a six-digit PIN code not starting with 0, qty must be an integer from 1 to 20, express is a boolean that gives null when not understood, and email must be a valid email. Run it on ['pin' => '302001', 'qty' => '25', 'express' => 'yes', 'email' => 'isha@example'] and print one line per field showing OK with the value, or invalid.

Show solution
PHP
<?php
declare(strict_types=1);

$rules = [
    'pin' => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/^[1-9][0-9]{5}$/']],
    'qty' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 1, 'max_range' => 20]],
    'express' => ['filter' => FILTER_VALIDATE_BOOL, 'flags' => FILTER_NULL_ON_FAILURE],
    'email' => FILTER_VALIDATE_EMAIL,
];

$input = ['pin' => '302001', 'qty' => '25', 'express' => 'yes', 'email' => 'isha@example'];

foreach (filter_var_array($input, $rules) as $field => $value) {
    $ok = $value !== false && $value !== null;
    echo $field, ': ', $ok ? 'OK ' . var_export($value, true) : 'invalid', "\n";
}
Output
pin: OK '302001'
qty: invalid
express: OK true
email: invalid

qty fails the range check and isha@example has no domain ending. For express, a false result would mean "no", so in a real form you would treat only null as invalid for that field.

Quick Quiz

1. What does filter_var('12abc34', FILTER_SANITIZE_NUMBER_INT) return?

2. Which value does filter_var('maybe', FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) return?

3. Why check the scheme after FILTER_VALIDATE_URL?

4. In filter_var_array(), what happens to an input field that is not in your rules?

Learn PHP with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call