PHP: How do you convert a string to a number?

There is a cast for the conversion, and it works. The real question is usually a different one though: what should happen when the string is not a number at all? The cast answers that silently, and rarely in a way you can use.

The casts at a glance

input      (int)    (float)   is_numeric
'42'       42       42        true
'42.7'     42       42.7      true
'  42  '   42       42        true
'42abc'    42       42        false
'abc'      0        0         false
''         0        0         false
'1e3'      1000     1000      true
'0x1A'     0        0         false
'007'      7        7         true
'4,99'     4        4         false
'-3.5'     -3       -3.5      true
'+7'       7        7         true

Two of those rows are worth remembering. 'abc' becomes 0 — not null, not false, but a perfectly valid-looking number. And '4,99' becomes 4, which is probably the most common case of all in a German-language form.

Neither produces a message. Your code simply carries on calculating with a number that has nothing to do with the input.

The cast stays silent, the arithmetic speaks up

This is a difference worth knowing:

(int) '42abc'   : 42
'42abc' + 1     : 43    Warning: A non-numeric value encountered
(int) 'abc'     : 0
'abc' + 1       : TypeError: Unsupported operand types: string + int

The cast says nothing in any of these cases. The arithmetic, on the other hand, warns for a partially numeric string — and throws a TypeError for a completely non-numeric one.

The TypeError is new in PHP 8. In PHP 7 that was only a warning. So if you have $s + 0 sitting in your code as a conversion, since PHP 8 you have a potential crash there. It is one of those changes that is easy to miss during an upgrade, because it only strikes on invalid input.

What I recommend instead

When the input comes from outside — and it almost always does, otherwise you would already have a number — you do not want to cast, you want to check:

<?php
  $quantity = filter_var($_POST['quantity'] ?? '', FILTER_VALIDATE_INT);

  if ($quantity === false) {
    // input was not a valid integer
  }
?>
'42'     INT: 42       FLOAT: 42.0
'42.7'   INT: false    FLOAT: 42.7
'42abc'  INT: false    FLOAT: false
' 42 '   INT: 42       FLOAT: 42.0
''       INT: false    FLOAT: false
'0'      INT: 0        FLOAT: 0.0
'-3'     INT: -3       FLOAT: -3.0

filter_var() returns false for invalid input — that is, something distinguishable from every valid number. That is exactly what the cast cannot do.

Note that FILTER_VALIDATE_INT rejects the string '42.7'. That is usually what you want: somebody expecting a whole number does not want a silently rounded one.

If you prefer a default value to false:

<?php
  $quantity = filter_var($input, FILTER_VALIDATE_INT,
      ['options' => ['default' => 1]]);
?>

The classic with zero

Watch out when checking:

filter_var('0', FILTER_VALIDATE_INT) : 0
if ($r)           : false   <- 0 is falsy!
if ($r !== false) : true

'0' is valid input and returns 0. And 0 is falsy. Anyone checking with if (!$quantity) treats the valid input 0 as an error.

So use === false or !== false. The same trap as with array_search() and strpos().

The comma

In German-language forms every other user types 4,99. What PHP makes of it:

(float) '4,99'                   : 4       <- everything after the comma gone
is_numeric('4,99')               : false
str_replace(',', '.', ...) first : 4.99

4,99 € becomes four euros. Nobody notices, because 4 is a perfectly plausible number — until somebody checks the totals.

A str_replace(',', '.', $input) before the conversion clears that up. For amounts with thousands separators (1.234,56) you also have to strip the dots, in this order:

<?php
  $raw    = str_replace(['.', ','], ['', '.'], $input);
  $amount = filter_var($raw, FILTER_VALIDATE_FLOAT);
?>
'1.234,56' -> '1234.56'  -> 1234.56
'4,99'     -> '4.99'     -> 4.99
'1234.56'  -> '123456'   -> 123456.0    <- wrong
'1,234.56' -> '1.23456'  -> 1.23456     <- wrong

The bottom two rows show the limit of this approach: it assumes German notation. If somebody enters English-formatted input, it silently turns into something else entirely — 1234.56 becomes 123,456.

If you cannot rule that out, use NumberFormatter from the intl extension. It knows the conventions of the respective language:

<?php
  $nf     = new NumberFormatter('de_DE', NumberFormatter::DECIMAL);
  $amount = $nf->parse('1.234,56');   // 1234.56
?>

That does not make the problem go away entirely — you still have to know which language the user typed in. It is more reliable not to allow both notations in the form to begin with: an <input type="number"> always sends the server a dot as the decimal separator, regardless of what the user sees in the browser.

intval() with a base

This is something the cast cannot do:

intval('0x1A')      : 0
intval('0x1A', 16)  : 26
intval('1A', 16)    : 26
intval('012', 8)    : 10
intval('101', 2)    : 5
intval('0x1A', 0)   : 26

Base 0 means “look at the prefix”: 0x hexadecimal, a leading 0 octal, otherwise decimal. Useful when reading configuration files in which file permissions are written as 0644.

Where the numbers stop

Two limits worth knowing.

The size:

PHP_INT_MAX                 : 9223372036854775807
(int) '9223372036854775808' : 9223372036854775807   <- silently capped
'9223372036854775808' + 0   : float

The cast silently caps at PHP_INT_MAX. So with an ID from a foreign system you get a wrong but plausible-looking number. Such IDs are better left as strings — you do not do arithmetic with them anyway.

The precision:

0.1 + 0.2 == 0.3         : false
0.1 + 0.2                : 0.30000000000000004441
(int) ((0.1 + 0.7) * 10) : 7

The last line is the classic from the PHP manual. (0.1 + 0.7) * 10 is internally marginally less than 8, and (int) truncates rather than rounds. Out comes 7.

For monetary amounts that means: calculate in cents, so with whole numbers. Divide by 100 only on output. That spares you a whole class of bugs which otherwise surface eventually as “one cent difference” in the accounts.

is_numeric or ctype_digit?

Finally the difference that often gets overlooked:

'42'   is_numeric: true   ctype_digit: true
'4.2'  is_numeric: true   ctype_digit: false
'-4'   is_numeric: true   ctype_digit: false
' 42'  is_numeric: true   ctype_digit: false
'42 '  is_numeric: true   ctype_digit: false
'1e3'  is_numeric: true   ctype_digit: false
''     is_numeric: false  ctype_digit: false

is_numeric() accepts a sign, a decimal point, exponential notation and whitespace both before and after. If you mean “digits only” — for a postcode or a customer number — use ctype_digit().

And note that ctype_digit('') also returns false, so an empty string does not slip through.

Summary

  • For input from outside use filter_var() rather than a cast — it can tell “invalid” from “a valid zero”.
  • Check with === false, not for falsiness.
  • (int) 'abc' is 0, without any message at all.
  • 'abc' + 1 is a TypeError since PHP 8, no longer a warning.
  • (float) '4,99' is 4 — rewrite German-formatted input first.
  • Large numbers are silently capped at PHP_INT_MAX by the cast.
  • Calculate monetary amounts in cents, not in euros.
  • ctype_digit() when you really do mean digits only.

About Netcup (advertisement)

The German host Netcup offers, among other things, affordable and powerful web hosting packages, KVM-based root servers and dedicated servers. With our voucher codes you can save even more (6€ off your first order, 30% off all KVM-based root servers, ...).