Strings7 min readExamples run on PHP 8.5.10

substr_compare()

Learn how PHP 8.4 substr_compare() compares part of a string from an offset, with optional length and case-insensitive matching, plus edge cases and mistakes.

Signature

substr_compare(
    string $haystack,
    string $needle,
    int $offset,
    ?int $length = null,
    bool $case_insensitive = false
): int

Purpose

substr_compare() compares a slice of one string with another string, without first creating the slice using substr(). You tell it where to start in the haystack, optionally how many bytes to compare, and whether letter case should be ignored. It is a natural fit for checks such as "does this filename end with .pdf?" or "does the text at position 4 match this keyword?".

The comparison is binary safe: it works on bytes, so embedded null bytes are treated like any other byte.

Signature

Parameters and defaults

  • $haystack: the main string. Comparison begins inside it.
  • $needle: the string compared against the part of the haystack.
  • $offset: the starting position in the haystack. A negative value counts from the end of the string.
  • $length: how many bytes to compare. The default null means the larger of the needle length and the haystack length minus the offset, so both remaining parts are compared in full.
  • $case_insensitive: when true, letter case is ignored. The default is false.

Return value and failure cases

The function returns an int: a value below zero, zero, or a value above zero. Only the sign carries meaning. Since PHP 8.2 the result is no longer guaranteed to be the difference of the string lengths, and may be -1 or 1. If your code needs a stable result, normalize it with <=> 0.

The manual page text still mentions a warning and false for bad arguments, but the declared return type is int. In PHP 8 an $offset greater than the haystack length, or a negative $length, raises a ValueError. An offset exactly equal to the haystack length is allowed, and the haystack part is then empty. A $length of 0 compares nothing and returns 0.

Example 1: a case-insensitive suffix check

A negative offset makes suffix tests short. The helper guards two cases first: an empty suffix, and a suffix longer than the text. Without the second guard, a negative offset beyond the start is clamped to the start of the string, and the comparison would run against the whole text.

Example
<?php
function endsWithIgnoreCase(string $text, string $suffix): bool
{
    if ($suffix === '') {
        return true;
    }
    if (strlen($suffix) > strlen($text)) {
        return false;
    }
    return substr_compare($text, $suffix, -strlen($suffix), null, true) === 0;
}

echo json_encode([
    endsWithIgnoreCase("Report.PDF", ".pdf"),
    endsWithIgnoreCase("Report.txt", ".pdf"),
    endsWithIgnoreCase("a", ".pdf"),
]), PHP_EOL;
Output
[true,false,false]

Example 2: the effect of length

The $length argument limits how much of each side is compared. With length 2, the extra g in bcg is never examined. With length 3, the haystack part bcd is longer than bc, so the haystack sorts after the needle. The example normalizes each result to -1, 0 or 1.

Example
<?php
$results = [
    substr_compare("abcde", "bc", 1, 2) <=> 0,
    substr_compare("abcde", "bcg", 1, 2) <=> 0,
    substr_compare("abcde", "BC", 1, 2, true) <=> 0,
    substr_compare("abcde", "bc", 1, 3) <=> 0,
    substr_compare("abcde", "cd", 1, 2) <=> 0,
];
echo json_encode($results), PHP_EOL;
Output
[0,0,0,1,-1]

Example 3: boundary offsets and errors

An offset equal to the haystack length is valid and compares an empty string with the needle. A larger offset throws ValueError. A zero length returns 0 regardless of the strings.

Example
<?php
echo json_encode(substr_compare("abcde", "abc", 5, 1) <=> 0), PHP_EOL;

try {
    substr_compare("abcde", "abc", 6);
} catch (ValueError $e) {
    echo "ValueError", PHP_EOL;
}

echo json_encode(substr_compare("abcde", "zzz", 1, 0)), PHP_EOL;
Output
-1
ValueError
0

Edge cases

  • Negative offsets count from the end. If the negative value reaches before the first byte, the start is clamped to the beginning of the string rather than failing.
  • Default length with an empty needle: the remaining haystack is compared with an empty string, so the result is nonzero unless the remaining part is also empty.
  • Length shorter than both parts: only that many bytes are compared, so strings that differ later still count as equal.
  • Multibyte text: offsets and lengths are byte counts, not characters. A UTF-8 character can span several bytes, so an offset may land in the middle of one. Use ASCII data, or the mb_ functions, when characters matter.

Common mistakes

  1. Treating the result as a boolean. 0 means equal, which is falsy. Test === 0 for equality.
  2. Relying on exact numbers. Only the sign is meaningful, and PHP 8.2 changed the possible magnitudes. Compare against 0 or use <=> 0.
  3. Forgetting the length guard in suffix checks. A needle longer than the haystack combined with a negative offset can compare the wrong region.
  4. Passing an offset past the end. In PHP 8 this throws ValueError, so validate the offset or catch the exception.
  5. Expecting a search. The function compares at one position only. To find where a string occurs, use strpos() or a related function.
  • strncmp() compares the first n bytes of two strings.
  • strcmp() and strcasecmp() compare whole strings.
  • substr() extracts a slice when you need the text itself.
  • str_ends_with() and str_starts_with() give a simpler case-sensitive prefix or suffix test.

Recap

Use substr_compare() to compare a byte range of the haystack with a needle. Choose the start with $offset, limit the work with $length, and ignore case with the last argument. Check the result with === 0 or normalize its sign, keep offsets within the string, and remember that all positions are measured in bytes.

Tested examples

Case-insensitive suffix check with a negative offset
<?php
function endsWithIgnoreCase(string $text, string $suffix): bool
{
    if ($suffix === '') {
        return true;
    }
    if (strlen($suffix) > strlen($text)) {
        return false;
    }
    return substr_compare($text, $suffix, -strlen($suffix), null, true) === 0;
}

echo json_encode([
    endsWithIgnoreCase("Report.PDF", ".pdf"),
    endsWithIgnoreCase("Report.txt", ".pdf"),
    endsWithIgnoreCase("a", ".pdf"),
]), PHP_EOL;
Output
[true,false,false]
How length changes the comparison
<?php
$results = [
    substr_compare("abcde", "bc", 1, 2) <=> 0,
    substr_compare("abcde", "bcg", 1, 2) <=> 0,
    substr_compare("abcde", "BC", 1, 2, true) <=> 0,
    substr_compare("abcde", "bc", 1, 3) <=> 0,
    substr_compare("abcde", "cd", 1, 2) <=> 0,
];
echo json_encode($results), PHP_EOL;
Output
[0,0,0,1,-1]
Boundary offsets, ValueError and zero length
<?php
echo json_encode(substr_compare("abcde", "abc", 5, 1) <=> 0), PHP_EOL;

try {
    substr_compare("abcde", "abc", 6);
} catch (ValueError $e) {
    echo "ValueError", PHP_EOL;
}

echo json_encode(substr_compare("abcde", "zzz", 1, 0)), PHP_EOL;
Output
-1
ValueError
0