skip to content

In PHP, why is if (strpos($code, 'INV')) a bug, and how should you test whether a string contains a substring?

level: juniorimportance: must knowfreq 80%

answer

  1. a match at the very start
  2. int|false return type
  3. 0 is falsy in a boolean test
  4. compare with !== false
  5. str_contains since PHP 8.0

basics

~20 s

strpos() returns the byte offset of the first match or false, and a match at offset 0 is falsy, so a plain if treats it as not found. Compare with !== false, or call str_contains(), added in PHP 8.0.

solid answer

~40 s

`strpos($haystack, $needle)` returns an `int` offset or `false`, and its return type is `int|false`. When the needle sits at the very start, the offset is `0`, and `if (strpos(...))` converts `0` to `false`, so a real match is treated as a miss. The loose `!= false` test has the same flaw because `0 == false` is true. The correct test is the strict `strpos($haystack, $needle) !== false`. Since PHP 8.0 the intent is clearer with `str_contains()`, `str_starts_with()` and `str_ends_with()`, which return a plain `bool`. Keep `strpos()` for when you need the offset itself, for example to cut a prefix with `substr()`, and always check its result against `false` strictly before using it.

code

php · 20 lines
php
<?php
declare(strict_types=1);

$code = 'INV-2026-0042';

var_dump(strpos($code, 'INV'));          // int(0)

if (strpos($code, 'INV')) {              // 0 is falsy: branch skipped
    echo "never printed\n";
}
if (strpos($code, 'INV') !== false) {    // strict test
    echo "found\n";
}
var_dump(str_starts_with($code, 'INV')); // bool(true)

$dash = strpos($code, '-');
if ($dash !== false) {
    echo substr($code, 0, $dash), "\n";  // INV
    echo substr($code, $dash + 1), "\n"; // 2026-0042
}

go deeper

for a junior

Recall that strpos returns an int offset or false, that offset 0 is falsy, and that the fix is !== false or str_contains from PHP 8.0.

for a middle

Explain the int|false contract across strpos, stripos and strrpos, the empty-needle rules of the PHP 8.0 predicates, and why an unchecked false turns into a wrong substr slice.

for a senior

Show you would sweep a legacy codebase for truthy strpos checks and substr === false branches, and let static analysis flag int|false results used without a strict check.

for a principal

Frame it as an API-design lesson: union return types that overload false invite bugs, which is why PHP added bool predicates and why team conventions should prefer them.

## What strpos() returns `strpos(string $haystack, string $needle, int $offset = 0): int|false` searches `$haystack` for the first occurrence of `$needle` and returns the **byte offset** where it starts. Offsets are zero-based, so a match at the very beginning of the string returns `0`. When the needle does not occur, the function returns `false`. The return type is a **union**: sometimes an integer, sometimes a boolean. That is the root of the classic bug, because PHP's boolean conversion treats `0` exactly like `false`. ## Why the truthiness test fails Consider an accounting export that checks whether a document code is an invoice number: ```php if (strpos($code, 'INV')) { // only runs when 'INV' is found at offset 1 or later } ``` For `$code = 'INV-2026-0042'` the call returns `0`. An `if` converts `0` to `false`, so the branch never runs for exactly the codes it was written for. The same happens with the loose comparison `!= false`, since `0 == false` is `true` in PHP. | Expression | `$code = 'INV-42'` | `$code = 'CN-42'` | Correct? | |---|---|---|---| | `if (strpos($code, 'INV'))` | skipped (returns `0`) | skipped (`false`) | no | | `strpos($code, 'INV') != false` | `false` | `false` | no | | `strpos($code, 'INV') > 0` | `false` | `false` | no | | `strpos($code, 'INV') !== false` | `true` | `false` | yes | | `str_contains($code, 'INV')` | `true` | `false` | yes | The only safe comparison with `strpos()` is the **strict** one, `!== false` (or `=== false` for the negative case), because it checks the type as well as the value. ## The PHP 8.0 predicates PHP 8.0 added three functions that answer the yes/no question directly and return `bool`: - **`str_contains($haystack, $needle)`**: does the needle occur anywhere? - **`str_starts_with($haystack, $needle)`**: does the haystack begin with it? - **`str_ends_with($haystack, $needle)`**: does the haystack end with it? All three are **case-sensitive** and byte-based. Each returns `true` for an **empty needle**, because the empty string occurs at every position; validate a needle that comes from user input before relying on the result. `str_starts_with($code, 'INV')` is also more precise than `str_contains()` for a prefix check, since `'CN-INV-7'` contains `INV` without starting with it. ## When you still need strpos() `strpos()` stays the right tool when the **position** matters, most often to slice a string with `substr(string $string, int $offset, ?int $length = null): string`: 1. Find the separator: `$dash = strpos($code, '-');` 2. Check it strictly: `if ($dash !== false) { ... }` 3. Slice: `substr($code, 0, $dash)` gives the prefix, `substr($code, $dash + 1)` the rest. If you skip step 2 and the separator is missing, `$dash + 1` quietly evaluates to `1`, and in a file without `strict_types` passing `false` as a length is coerced to `0`, so you get the wrong slice instead of an error. Related functions follow the same `int|false` contract: `stripos()` (ASCII case-insensitive), `strrpos()` (last occurrence) and `strripos()`. `strstr()` returns the matching tail as a `string` or `false`. ## Edge cases worth knowing - **Empty needle.** Since PHP 8.0 an empty needle is allowed in `strpos()` and matches at the start of the string; it used to be a warning. - **Non-string needle.** Since PHP 8.0 the needle is always treated as a string. Before 8.0, an integer needle was taken as an ASCII code point, so `strpos($s, 65)` searched for `A`; `chr(65)` restores that intent explicitly. - **Offset out of range.** An `$offset` larger than the haystack length throws a `ValueError`; a negative offset counts from the end. - **substr() out of range.** Since PHP 8.0 `substr()` returns `''` instead of `false` when the offset is past the end, and a `null` length means "to the end of the string". - **Bytes, not characters.** Every offset here counts bytes. In UTF-8 text a character can take several bytes, which is where the `mb_*` counterparts come in. ## What interviewers listen for A strong answer names the `int|false` return type, explains why `0` and `false` collide under loose comparison, gives the strict fix, and then reaches for `str_contains()` or `str_starts_with()` when only a yes/no answer is needed. Mentioning that `strpos()` is still the right choice when the offset feeds a `substr()` call shows you know both tools rather than just the newer one.

  • In PHP 8, what do str_contains(), str_starts_with() and str_ends_with() return for an empty needle?
    All three return `true`, for any haystack including the empty string, because the empty string occurs at every position. If the needle comes from user input, for example a search box filtering invoice codes, check that it is non-empty first, otherwise every row matches.
  • What changed in PHP 8.0 for substr() when the offset lies past the end of the string?
    It returns an empty string `''` instead of `false`; out-of-bounds offsets are clamped to the string boundary. A `null` length now means "to the end" rather than producing an empty string. Old code that tested `substr(...) === false` has a branch that can no longer fire.
  • Why does strpos($s, 65) no longer search for the letter A?
    Since PHP 8.0 the needle is always interpreted as a string, so the integer `65` is searched as the text "65"; with `strict_types=1` in the calling file it is a `TypeError` instead. Before 8.0 a non-string needle was an ASCII code point. Write `chr(65)` or `'A'` to say what you mean.

saying these in an interview costs you the question

  • strpos returns -1 when the needle is not found.
  • if (strpos($s, $x)) is fine because strpos returns true on a match.
  • strpos($s, $x) != false is just as safe as !== false.
  • str_contains ignores letter case.
  • str_contains returns false when the needle is an empty string.
  • substr still returns false in PHP 8 when the offset is beyond the string.