PHP 8.0 — str_contains, str_starts_with, str_ends_with
Concept
PHP 8.0 added three long-overdue string functions: str_contains, str_starts_with, and str_ends_with. They replace the verbose and error-prone strpos() !== false pattern with clear semantic functions.
The old way: strpos($haystack, $needle) !== false was the standard "does string contain" check. The !== false was essential — strpos returns 0 (truthy? no, falsy in PHP!) when the needle is at position 0, so if (strpos($s, 'prefix')) is broken when the prefix is at the start. Countless PHP bugs were caused by == false vs === false.
str_contains($haystack, $needle): Returns true if $needle appears anywhere in $haystack. Empty string as needle always returns true (consistent with the definition: any string contains the empty string).
str_starts_with($haystack, $prefix): Returns true if $haystack begins with $prefix. More efficient than substr($s, 0, strlen($prefix)) === $prefix. Empty prefix always returns true.
str_ends_with($haystack, $suffix): Returns true if $haystack ends with $suffix. Empty suffix always returns true.
Performance: All three are implemented in C, operate on byte strings (not Unicode), and are O(n) worst case. For very hot loops, they are faster than equivalent userland PHP. For Unicode-safe variants, use mb_strpos based checks (or implement them manually with mb_substr).
Code Example
<?php
declare(strict_types=1);
$email = 'user@example.com';
$route = '/api/v2/users/42';
$log = 'ERROR: Connection refused to db-01.internal';
// str_contains — replaces strpos !== false
if (str_contains($email, '@')) {
echo "Has @ symbol\n";
}
// The old buggy way
if (strpos('starts_at_zero', 'starts')) { // evaluates to 0 → FALSE! Bug!
echo "This never runs — position 0 is falsy\n";
}
// Correct old way
if (strpos('starts_at_zero', 'starts') !== false) {
echo "Found (correct but verbose)\n";
}
// PHP 8.0 way — clean and correct
if (str_contains('starts_at_zero', 'starts')) {
echo "Found (clear and correct)\n";
}
// str_starts_with — replaces substr check
if (str_starts_with($route, '/api/')) {
$version = explode('/', $route)[2]; // 'v2'
echo "API version: $version\n";
}
// str_ends_with — replaces strrev/substr tricks
if (str_ends_with($email, '.com')) {
echo "Commercial domain\n";
}
// Practical: dispatch by URL prefix
$routes = [
'/api/' => fn() => handleApi(),
'/admin/' => fn() => handleAdmin(),
'/health' => fn() => handleHealth(),
];
foreach ($routes as $prefix => $handler) {
if (str_starts_with($_SERVER['REQUEST_URI'] ?? '/', $prefix)) {
$handler();
break;
}
}
// Log level parsing
$logLine = '2024-01-15 ERROR: Something went wrong';
$level = match(true) {
str_contains($logLine, 'ERROR') => 'error',
str_contains($logLine, 'WARNING') => 'warning',
str_contains($logLine, 'INFO') => 'info',
default => 'debug',
};