0

PHP 8.0 — str_contains, str_starts_with, str_ends_with

Beginner5 min read·php-09-008

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
<?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',
};