symfony/polyfill-php83
Symfony polyfill for PHP 8.3 backports PHP 8.3 features to older PHP versions, including json_validate, the Override attribute, mb_str_pad, str_increment/str_decrement, Date* exceptions, LDAP helpers, and stream_context_set_options.
Check before you add. In a Laravel app you almost certainly already have this package transitively — symfony/http-foundation (and most Symfony 6.3+/7 components Laravel pulls in) require it. Confirm first:
composer show symfony/polyfill-php83 # present? which version?
composer why symfony/polyfill-php83 # who pulled it in?
If it's already there at ^1.43, there is nothing to install. Pin the floor explicitly only if you are about to call the polyfilled functions in application code:
composer require symfony/polyfill-php83:^1.43
Note: put it in require, not require-dev, if production code paths call json_validate() or str_increment(). A transitive dependency happens to save you here, but relying on that is fragile.
Confirm which implementation is live. The polyfill bootstraps with function_exists() guards, so on PHP 8.3+ you get the native functions and the package is inert. Verify rather than assume:
// php artisan tinker
dump(PHP_VERSION_ID); // 802xx => polyfilled
dump((new ReflectionFunction('json_validate'))->getFileName()); // null/"" => native
dump((new ReflectionClass('Override'))->getFileName()); // path => stub
Where to look first. vendor/symfony/polyfill-php83/bootstrap.php (what gets defined and under which PHP_VERSION_ID guards), Php83.php (the actual implementations), and Resources/stubs/ (the Override attribute and the Date*Exception / SQLite3Exception class declarations). If a behaviour surprises you, the answer is in Php83.php, not in the docs.
First use case: a JSON-string validation rule. Lowest-risk, highest-frequency win. json_validate() checks syntax without allocating a decoded structure, which is exactly what a "must be a JSON blob" field wants:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class JsonString implements ValidationRule
{
public function __construct(private int $depth = 512) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! json_validate($value, $this->depth)) {
$fail('The :attribute field must be a valid JSON string.');
}
}
}
Then 'payload' => ['required', new JsonString()]. Do this on PHP 8.1/8.2 and again after upgrading to 8.3; the test suite should not change.
Route polyfilled calls through one app-level class. The exit strategy matters more than the call itself. Wrapping means removing the polyfill later is a one-file diff rather than a grep-and-pray:
namespace App\Support;
final class Json
{
public static function isValid(mixed $payload, int $depth = 512): bool
{
return is_string($payload) && json_validate($payload, $depth);
}
}
The same pattern works for mb_str_pad and the string increment helpers. Do not scatter bare function_exists('json_validate') guards through controllers — the polyfill already guards the definition; guarding the call site only adds noise.
Depth control where Laravel's built-in json rule is not enough. Laravel's validator already has a json rule (decode + json_last_error()), so reach for the polyfill when you need a shallower depth limit (reject 512-deep payloads at 64) or when you are validating outside the validator — queue job payloads, webhook bodies, signed-request middleware:
if (! Json::isValid($request->getContent(), depth: 64)) {
abort(422, 'Malformed JSON payload.');
}
Validate once, decode once. Never json_validate() and then json_decode() and then check the error again — the second check is dead code and the double parse is wasted work on large payloads.
#[\Override] on abstract repositories and base services. The attribute is documentation-plus-safety-net: it asserts the method really overrides a parent or implements an interface member. Use it fully qualified (#[\Override]) rather than importing it, so the file means the same thing on PHP 8.3 and below:
abstract class BaseRepository
{
abstract public function find(int $id): ?Model;
}
final class InvoiceRepository extends BaseRepository
{
#[\Override]
public function find(int $id): ?Model { /* ... */ }
}
str_increment / str_decrement for separator-free alphanumeric sequences. These are base-62-ish counters (0-9A-Za-z) that carry correctly — str_increment('AZ') gives 'BA', str_decrement('BA') gives 'AZ'. Realistic Laravel uses: spreadsheet-style column labels, human-readable short codes, suffix rotation. For INV-2024-0001 style numbers, split the suffix, increment, and re-pad with Str::padLeft() — or better, keep an integer column and pad on render:
$next = str_pad(str_increment($lastSuffix), 4, '0', STR_PAD_LEFT);
Wrap it in a creating observer and rely on a unique index plus a retry for concurrency — the polyfill is a pure function, not a sequence generator.
mb_str_pad for console and report output. Anything you print in an Artisan command, a fixed-width export, or a table is subject to byte-vs-character drift with str_pad. Pass the encoding explicitly rather than trusting mb_internal_encoding():
$this->line(mb_str_pad($label, 28, ' ', STR_PAD_RIGHT, 'UTF-8') . $value);
Date exceptions in catch blocks, written portably. Catching DateMalformedStringException / DateRangeError is safe on 8.2 because the polyfill declares the classes — but see the gotcha below before you assume behaviour parity. Keep the catch narrow and let App\Exceptions\Handler deal with the rest; do not collapse DateError (an Error) into a generic catch (\Exception).
LDAP / stream polyfills: audit before adopting. ldap_exop_sync, ldap_connect_wallet, and stream_context_set_options only matter if you have an LDAP integration (directorytree/ldaprecord does not need them) or hand-rolled stream contexts. They are not day-to-day Laravel tools.
Polyfills backport functions and classes, never syntax. Typed class constants, dynamic class constant fetch, and readonly deep-clone semantics are PHP 8.3 grammar — they will still be parse errors on 8.2 no matter what you composer require. Only reach for this package for the enumerated runtime features.
#[\Override] is a no-op below PHP 8.3. The stub declares the attribute so the file parses; nothing verifies it. A method with a typo'd name passes CI on 8.2 and produces a fatal compile error the moment you bump the runtime to 8.3. Mitigate with PHPStan/Psalm configured for the target phpVersion (they validate Override when the target is 8.3+), and treat the PHP-version bump PR as a code review checkpoint, not a dependency bump.
On PHP 7.x, #[ is a comment. If any environment still runs PHP 7.4 (Laravel 8/9 territory), an attribute written inline — #[Override] public function foo() — comments out the rest of the line and produces a parse error. Keep attributes on their own line, always.
Declared exception classes ≠ thrown exceptions. On PHP < 8.3 the native date functions still emit warnings and return false; they do not throw DateMalformedStringException. Your catch block is portable, but the failure mode differs by runtime. Differential tests are the only way to keep this honest.
json_validate() is strict about its argument. It takes string; passing an array is a TypeError, not false. Form-encoded input, or a field the client sent as a nested array, will blow up where you expected a validation failure — hence the is_string() guard. It also accepts only JSON_INVALID_UTF8_IGNORE / JSON_INVALID_UTF8_SUBSTITUTE in the $flags parameter; passing JSON_THROW_ON_ERROR raises ValueError.
json_validate() validates syntax only. It says nothing about shape, required keys, or types. It is not a replacement for a FormRequest or a schema validator — it is a cheap gate in front of them.
str_increment / str_decrement throw. ValueError for empty strings, non-alphanumeric input, and underflow. They are also not concurrency-safe and not a substitute for an auto-increment column. Normalise and validate input before calling, and unit-test the underflow boundaries — this is precisely the surface that #691 fixed, so pin ^1.43 and keep the regression tests.
Audit for hand-rolled polyfills before rollout. A global function json_validate() in app/Helpers/ will collide fatally, because Composer's files autoload (which includes the polyfill bootstrap) runs at require vendor/autoload.php — before any of your helper files. Grep first:
grep -rn "function json_validate\|function mb_str_pad\|function str_increment\|function str_decrement" app/ packages/ bootstrap/
Pin the floor at ^1.43. #691 (str_increment over PHP_INT_MAX), #694 (json_validate edge cases), and #670 (mb_str_pad allocation) are all correctness-relevant. A floating range means different behaviour across environments.
Composer config.platform.php does not turn the polyfill off. The bootstrap branches on PHP_VERSION_ID, the real runtime constant. But a platform config that disagrees with the CI matrix will silently hide the difference between polyfilled and native paths — keep them aligned, or better, run both.
Run a two-job CI matrix. One job on your floor version (polyfilled) and one on PHP 8.3+ (native), executing the same tests. #691 and #694 are proof that parity gaps exist and surface late; differential tests are how you find them before production does.
mb_str_pad is character-counted, not byte-counted. $length is a character length, so column widths in console output won't match str_pad, and STR_PAD_BOTH centring lands differently. If you are padding in a hot loop generating reports, measure — #670 reduced allocation pressure but did not make it free.
Debugging shortcut. When behaviour differs between environments, ask the runtime directly:
dump((new ReflectionFunction('str_increment'))->getFileName()); // polyfill path?
dump(function_exists('stream_context_set_options'));
Plan the exit. Don't composer remove symfony/polyfill-php83 when you hit PHP 8.3 — it is transitive and will come straight back. The real exit is deleting your wrapper class (App\Support\Json) and the #[\Override] attributes becoming enforced for free. Track that as a cleanup item in the PHP-bump ticket, and note the version floor in UPGRADING.md alongside the reason.
How can I help you explore Laravel packages today?