substr_compare
Compares part of one string with another string, starting at a given offset, without copying it.
substr_compare(string $haystack, string $needle, int $offset, ?int $length = null, bool $case_insensitive = false): int- Added in PHP 5.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$haystack | string | The main string being compared. |
$needle | string | The secondary string being compared against the haystack portion. |
$offset | int | The position in haystack where the comparison starts. Negative values count back from the end of the string. |
$lengthoptional | ?int= null | How many bytes to compare. When null, the length used is the larger of the needle length and the remaining haystack length after offset. |
$case_insensitiveoptional | bool= false | If true, the comparison ignores ASCII case. |
Return value
int0 if the two compared parts are equal, a negative number if the haystack portion sorts before the needle, and a positive number if it sorts after.
Errors
Throws a ValueError if offset is greater than the length of haystack, or if length is negative.
Changelog
- PHP 8.2.0The function is no longer guaranteed to return strlen($haystack) - strlen($needle) when the lengths differ; it may return -1 or 1 instead. Compare against 0, not against a specific number.
- PHP 8.0.0length is nullable now.
- PHP 7.2.18, 7.3.5offset may now be equal to the length of haystack, which previously raised a warning.
substr_compare compares a slice of haystack against needle without allocating that slice first. It returns an strcmp-style integer: zero means equal, negative means the haystack part sorts first.
When to reach for it
Its niche is checking a prefix or suffix on large strings in a hot loop, where substr($s, -4) === '.csv' would copy bytes on every iteration. It is also the only core function that does an offset-based comparison with an optional case-insensitive flag in one call, which is convenient for parsing fixed-layout records where a field must match a constant.
For plain readability though, str_starts_with and str_ends_with (PHP 8.0+) are clearer for the common cases and should be your default.
What trips people up
Only the sign of the result is meaningful. Since PHP 8.2 the function may return -1 or 1 instead of the length difference, so any code doing arithmetic on the return value, or comparing it to a specific integer, is broken. Always test === 0 or < 0.
An offset beyond the end of the haystack throws a ValueError rather than returning something — validate the length first if the offset is computed.
Example
When to use it
- Check whether a filename ends with a given extension without copying the tail of the string.
- Verify a fixed-position record type code in a flat-file line during a large import.
- Do a case-insensitive prefix check on a header value without allocating a lowercased copy.
Frequently asked questions
What is the difference between substr_compare and str_ends_with?
str_ends_with is simpler and returns a bool, but it has no offset or case-insensitive option. substr_compare can compare at any offset, limit the comparison length, and fold case, and it avoids allocating a substring.
Why does substr_compare return 1 instead of the length difference?
As of PHP 8.2 the exact magnitude is no longer guaranteed; only the sign is defined. Test the result with === 0, < 0 or > 0.
Does substr_compare throw if the offset is too large?
Yes — an offset greater than the haystack length throws a ValueError. An offset exactly equal to the length has been allowed since PHP 7.2.18 and 7.3.5.
More examples
Reading a fixed-layout record
Prints 'other row: HDR', 'detail row: 000123widget', 'other row: TRL'. The comparison reads the three-byte record type in place, which matters when the loop runs over millions of lines.
<?php
$lines = ['HDR20240101ACME', 'DTL000123widget', 'TRL000002'];
foreach ($lines as $line) {
if (substr_compare($line, 'DTL', 0, 3) === 0) {
echo 'detail row: ', substr($line, 3), "\n";
} else {
echo 'other row: ', substr($line, 0, 3), "\n";
}
}Guarding against a ValueError
Prints bool(true) then bool(false). The length check is essential: without it the second call would pass an offset larger than the haystack and throw a ValueError instead of returning false.
<?php
function endsWithCi(string $s, string $suffix): bool {
$len = strlen($suffix);
if ($len > strlen($s)) {
return false;
}
return substr_compare($s, $suffix, -$len, $len, true) === 0;
}
var_dump(endsWithCi('archive.TAR.GZ', '.tar.gz')); // true
var_dump(endsWithCi('a', '.tar.gz')); // false
Discussion