Strings7 min readExamples run on PHP 8.5.10

substr()

Learn how PHP 8.4 substr() extracts part of a string by byte offset and length, including negative values, out-of-range input and multibyte pitfalls.

Signature

substr(string $string, int $offset, ?int $length = null): string

Purpose

substr() returns a slice of a string. You choose where the slice starts with offset and, optionally, how long it is with length. It is the standard tool for taking a prefix, a suffix, or a fixed window from a string whose layout you already know, such as the last four digits of an identifier or the first characters of a title.

Signature

Parameters and defaults

  • $string: the input. Positions are counted in bytes, starting from zero.
  • $offset: where the slice begins. A non-negative value counts from the start. A negative value counts back from the end, so -1 is the last byte. If the offset is larger than the string, the result is an empty string. If a negative offset reaches back past the start, it is treated as the start of the string.
  • $length: optional, default null. A positive value gives at most that many bytes. A negative value omits that many bytes from the end of the string. If the end point lands at or before the start position, the result is an empty string. A length of 0 returns an empty string. Omitting it, or passing null, returns everything from the offset to the end.

Return value and failure cases

substr() always returns a string. Since PHP 8.0.0 an invalid range gives an empty string rather than false, so you no longer need a false check. Also since 8.0.0, length is nullable, and an explicit null means "to the end of the string". Before that, null produced an empty string.

Out-of-range numbers do not raise errors. A TypeError occurs only when an argument cannot be used as the required type, for example an array passed as the string. In ordinary non-strict mode, scalars are converted: substr(54321, 0, 2) gives '54', and substr(false, 0, 1) gives ''. An object with __toString() is also accepted. Under declare(strict_types=1), passing an integer as the string is a type error.

Example 1: offsets and lengths

The first example shows a start offset, a length, and negative offsets counted from the end.

Example
<?php
$s = 'abcdef';
echo json_encode([
    substr($s, 1),
    substr($s, 1, 3),
    substr($s, -2),
    substr($s, -3, 1),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), PHP_EOL;
Output
["bcdef","bcd","ef","d"]

Example 2: negative length and empty results

A negative length trims from the right. When the range is empty or invalid, you get "".

Example
<?php
$s = 'abcdef';
echo json_encode([
    substr($s, 0, -1),
    substr($s, 2, -1),
    substr($s, 4, -4),
    substr($s, -3, -1),
    substr($s, 10),
    substr($s, 1, null),
    substr($s, 2, 0),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), PHP_EOL;
Output
["abcde","cde","","de","","bcdef",""]

The third result is empty because the end point (position 2) lies before the start (position 4). The fifth is empty because the offset is beyond the end of the string.

Example 3: masking and previews

Two small helpers show everyday use. The first keeps the last four characters and masks the rest. The second shortens long text.

Example
<?php
function maskCard(string $number): string
{
    $last4 = substr($number, -4);
    return str_repeat('*', max(0, strlen($number) - 4)) . $last4;
}

function preview(string $text, int $max): string
{
    return strlen($text) > $max ? substr($text, 0, $max) . '...' : $text;
}

echo json_encode([
    maskCard('4111111111111111'),
    maskCard('123'),
    preview('PHP substring basics', 10),
    preview('short', 10),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), PHP_EOL;
Output
["************1111","123","PHP substr...","short"]

Notice that maskCard('123') still works. The negative offset -4 reaches past the start of a three-byte string, so PHP begins at the start and returns 123.

Edge cases

  • Empty input: substr('', 0, 1) returns an empty string.
  • Offset equal to the length: the result is an empty string, because there is nothing left to take.
  • Length longer than the remainder: the result is simply shortened, with no padding.
  • Single characters: for one byte you can also use square brackets, such as $string[0]. Unlike substr(), reading an index that does not exist produces a warning instead of an empty string.

Common mistakes

  1. Treating bytes as characters. substr() counts bytes. In UTF-8 text, characters such as accented letters or emoji use several bytes, and cutting in the middle produces invalid text. Use mb_substr() when the input may contain multibyte characters.
  2. Mixing up length and end position. The third argument is a count, not an index. substr('abcdef', 2, 3) returns cde, not the characters up to position 3.
  3. Expecting false on failure. That was the behaviour before PHP 8.0.0. Checking === false today never triggers.
  4. Forgetting what a negative length means. It removes characters from the end. It does not reverse direction.
  5. Using it to test for a prefix. For a simple check, a dedicated function such as str_starts_with() states the intent more clearly than comparing a slice.
  • mb_substr(): the multibyte-aware version.
  • substr_replace(): replaces a portion of a string.
  • strrchr(): finds the last occurrence of a character and returns the rest of the string.
  • trim(): removes whitespace or characters from the ends.
  • wordwrap(): wraps text at a given width.
  • preg_match(): extracts text by pattern rather than by position.

Recap

substr($string, $offset, $length) slices by byte position. Negative offsets count from the end, and negative lengths trim from the end. A null or omitted length means "to the end". Invalid ranges return "" in PHP 8.4, and multibyte text needs mb_substr().

Tested examples

Offsets and lengths
<?php
$s = 'abcdef';
echo json_encode([
    substr($s, 1),
    substr($s, 1, 3),
    substr($s, -2),
    substr($s, -3, 1),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), PHP_EOL;
Output
["bcdef","bcd","ef","d"]
Negative length and empty results
<?php
$s = 'abcdef';
echo json_encode([
    substr($s, 0, -1),
    substr($s, 2, -1),
    substr($s, 4, -4),
    substr($s, -3, -1),
    substr($s, 10),
    substr($s, 1, null),
    substr($s, 2, 0),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), PHP_EOL;
Output
["abcde","cde","","de","","bcdef",""]
Masking and previews
<?php
function maskCard(string $number): string
{
    $last4 = substr($number, -4);
    return str_repeat('*', max(0, strlen($number) - 4)) . $last4;
}

function preview(string $text, int $max): string
{
    return strlen($text) > $max ? substr($text, 0, $max) . '...' : $text;
}

echo json_encode([
    maskCard('4111111111111111'),
    maskCard('123'),
    preview('PHP substring basics', 10),
    preview('short', 10),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), PHP_EOL;
Output
["************1111","123","PHP substr...","short"]