PHP's default trim list has been " \n\r\t\v\0" for the entire life of the language. PHP 8.6 adds one character to it. That sounds like a footnote until you notice how many places in a Laravel app call trim() on a string that came from a user — and that the Laravel pieces you'd expect to break are fine, while one you probably haven't thought about is not.
PHP 8.6.0 is due on 19 November 2026, currently at RC5, so this is worth absorbing before the image bump rather than after.
What PHP 8.6 changes about trim(), ltrim() and rtrim()#
RFC: Add Form Feed in Trim Functions passed 25–0 and shipped in 8.6. The form feed character — \f, ASCII 12, 0x0C, the old page-break control code — joins the default $characters mask:
// PHP 8.5 and earlier
trim(string $string, string $characters = " \n\r\t\v\x00"): string
// PHP 8.6
trim(string $string, string $characters = " \f\n\r\t\v\x00"): string
The practical difference:
<?php
$value = "\fAcme Ltd\f";
// PHP 8.5
var_dump(trim($value)); // string(11) "\fAcme Ltd\f"
var_dump(trim("\f")); // string(1) "\f" — non-empty
// PHP 8.6
var_dump(trim($value)); // string(8) "Acme Ltd"
var_dump(trim("\f")); // string(0) "" — empty
chop is an alias of rtrim(), so it inherits the new default too. The RFC is explicit that this is a backward incompatible change, and the reasoning is consistency: C's isspace(), PHP's own is_numeric(), Python's str.strip(), JavaScript's String.prototype.trim(), Go's strings.TrimSpace() and Rust's str::trim() all treat \f as whitespace already.
Where does a form feed realistically come from? Text pasted out of a PDF or Word document, legacy printer-oriented exports, fixed-width mainframe extracts, and some CSV dumps. Rare, but not zero — and "rare" is exactly the profile of a bug that reaches production.
Laravel's TrimStrings middleware is already safe#
The obvious worry is Illuminate\Foundation\Http\Middleware\TrimStrings, which runs over every request string in the default stack. Read it in Laravel 13 and the worry evaporates:
// vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php
protected function transform($key, $value)
{
$except = array_merge($this->except, static::$neverTrim);
if ($this->shouldSkip($key, $except) || ! is_string($value)) {
return $value;
}
return Str::trim($value); // not trim()
}
Str::trim() doesn't delegate to trim() at all when no charlist is given. It builds a PCRE character class instead:
// Illuminate\Support\Str
public static function trim($value, $charlist = null)
{
if ($charlist === null) {
$trimDefaultCharacters = " \n\r\t\v\0";
return preg_replace(
'~^[\s'.self::INVISIBLE_CHARACTERS.$trimDefaultCharacters.']+|[\s'.self::INVISIBLE_CHARACTERS.$trimDefaultCharacters.']+$~u',
'',
$value
) ?? trim($value);
}
return trim($value, $charlist);
}
\s is doing the work. PCRE2's default \s set is HT (9), LF (10), VT (11), FF (12), CR (13) and space (32) — form feed has always been in there. Laravel has been stripping \f from request input for as long as TrimStrings has routed through Str::trim(), plus a long list of zero-width and invisible Unicode characters that native trim() still ignores. PHP 8.6 is catching up to the framework, not breaking it.
Two caveats before you file this under "nothing to do":
Str::trim()falls back to nativetrim($value)whenpreg_replace()returnsnull, which happens on invalid UTF-8. That fallback path does change behaviour on 8.6.- If you're on an older Laravel whose
TrimStringscallstrim()directly, read the vendor file yourself rather than trusting this paragraph. The multibyte story is its own topic — I covered the related functions in PHP 8.4's mb_trim() and mb_ucfirst().
Where the PHP 8.6 trim form feed change actually bites#
The validator. ValidatesAttributes::validateRequired() uses native trim():
// Illuminate\Validation\Concerns\ValidatesAttributes
public function validateRequired($attribute, $value)
{
if (is_null($value)) {
return false;
} elseif (is_string($value) && trim($value) === '') {
return false;
}
// ...
}
So a string whose entire content is a form feed flips:
Validator::make(['name' => "\f"], ['name' => 'required'])->passes();
// PHP 8.5 → true
// PHP 8.6 → false
For a normal web request this is academic — TrimStrings and ConvertEmptyStringsToNull already turned that value into null and required already failed. It matters wherever validation runs on data that never passed through the HTTP middleware stack:
- Artisan commands and queued jobs calling
Validator::make()on an array they built themselves. - Imports — CSV, XML, a vendor feed — validated row by row.
- API routes where the field sits in
TrimStrings::$except, or a request matched by aTrimStrings::skipWhen()callback. - Anything validating a decoded JSON body you fetched with
Http::get().
The same file has a protected trim() helper used by the numeric comparison rules (gt, lt, decimal, and friends), which also picks up the new default. "5\f" compared against a threshold was a malformed number on 8.5 and is a clean 5 on 8.6.
Both directions are a behaviour change rather than a crash, which is the kind that slips past a smoke test. If you surface validation errors as the user types with Laravel Precognition, the client and the server have to agree on this, so make sure both run the same PHP minor.
Audit what you wrote before the upgrade#
The subtler problem is data at rest. A row saved on PHP 8.5 can carry "acme\f". The same input saved on 8.6 lands as "acme". Now where('slug', $value) misses, the unique rule stops seeing the duplicate, and a dedupe job creates a second record.
One query tells you whether you have any:
-- MySQL / MariaDB
SELECT id, name FROM companies WHERE name LIKE CONCAT('%', CHAR(12), '%');
-- PostgreSQL
SELECT id, name FROM companies WHERE name LIKE '%' || chr(12) || '%';
Or from Eloquent, across the columns you care about:
Company::query()
->where('name', 'like', '%'.chr(12).'%')
->orWhere('slug', 'like', '%'.chr(12).'%')
->get(['id', 'name', 'slug']);
Clean those up in a one-off migration or command before the PHP upgrade, not after. The window where half your rows are trimmed one way and half the other is the expensive part.
Pin the behaviour with a test#
Whichever behaviour you want, assert it, so the next PHP bump can't move it silently:
it('treats a form-feed-only value as missing', function () {
$passes = Validator::make(['name' => "\f"], ['name' => 'required'])->passes();
expect($passes)->toBeFalse();
});
it('keeps form feeds when the charlist is explicit', function () {
// Identical output on PHP 8.5 and 8.6
expect(trim("\fAcme\f", " \n\r\t\v\0"))->toBe("\fAcme\f");
});
That second test is the escape hatch in general: passing $characters explicitly freezes the behaviour across both versions, which is what you want in library code that supports a range of PHP versions. Going the other way, trim($value, " \n\r\t\v\0\f") on PHP 8.5 opts into 8.6's behaviour early, so the change is already absorbed when the runtime moves. Run the suite on both minors in CI — a GitHub Actions matrix across PHP and database versions catches this class of change for the price of a few extra runners.
Gotchas and Edge Cases#
Snapshot tests. Stored snapshots containing a trimmed string with a trailing \f will mismatch after the upgrade, and the diff is invisible in a terminal because the character doesn't render. If you're snapshot testing API responses, expect a round of regenerations and read the byte-level diff rather than the pretty one.
Caches and queue payloads. A value trimmed under the old rules and cached, or serialised into a job payload before the deploy and consumed after it, carries 8.5 semantics into an 8.6 process. Short cache TTLs and a drained queue before the upgrade make this a non-issue.
Static analysis stubs. The RFC calls out that analysers infer trim("\f") as a non-empty string today. Until PHPStan and Psalm ship updated stubs, a non-empty-string return type on that path may be wrong. Bump your analyser alongside PHP.
Not affected. mb_trim() has its own default list. preg_replace('/\s+/', ...) already matched \f. Str::trim(), Str::ltrim(), Str::rtrim() and the Stringable wrappers around them are unchanged except on the invalid-UTF-8 fallback. The RFC deliberately left other trimming functions, such as the PHP filter extension's, out of scope.
Wrapping Up#
Read your own trim() calls, not the headlines: the framework's middleware was already ahead of this change, and the real exposure is validation that runs outside the HTTP stack plus rows written before the upgrade. Run the CHAR(12) audit query, add the two tests above, and this one costs you an afternoon. Then work through the rest of the release — I keep a running Laravel upgrade checklist for PHP 8.6, and the secure session defaults are the other 8.6 behaviour change that reaches into a Laravel app without you writing a line of code.
FAQ#
What characters does trim() remove in PHP 8.6?
By default trim() removes space (0x20), tab (\t), line feed (\n), carriage return (\r), vertical tab (\v), NUL (\0) and, new in PHP 8.6, form feed (\f, 0x0C). The same list applies to ltrim(), rtrim() and rtrim()'s alias chop. Passing the second $characters argument replaces that list entirely rather than adding to it.
Is the PHP 8.6 trim() change a breaking change?
Yes, the RFC classifies it as a backward incompatible change. Any code that relied on trim() preserving a leading or trailing form feed now gets a different string, and trim("\f") returns an empty string where it previously returned a one-character string. In practice the blast radius is small because form feeds are rare in user input, but it is a real behaviour change rather than a documentation fix.
What is a form feed character in PHP?
A form feed is ASCII character 12 (0x0C), written as "\f" in a double-quoted PHP string. It originated as a printer control code telling the device to advance to the top of the next page, and it survives today inside text extracted from PDFs and word processors, fixed-width mainframe exports and some legacy CSV dumps. It is invisible in most editors and terminals, which is why it tends to travel through a system unnoticed.
Does Laravel's TrimStrings middleware use trim()?
Not in Laravel 13. TrimStrings::transform() calls Str::trim(), which uses a preg_replace() character class rather than the native function, and PCRE's \s has always included form feed. The middleware therefore stripped \f from request input before PHP 8.6 existed. It only falls back to native trim() when the regex fails on invalid UTF-8, so check the vendor file for your own Laravel version if you are on an older release.
How do I keep PHP 8.5 trim() behaviour in PHP 8.6?
Pass the old character list explicitly: trim($value, " \n\r\t\v\0"). That produces identical results on PHP 8.5 and PHP 8.6 and is the right approach for library code supporting several PHP versions. The reverse also works for a staged migration — calling trim($value, " \n\r\t\v\0\f") on PHP 8.5 gives you 8.6's behaviour early, so nothing changes when the runtime is upgraded.