symfony/polyfill-php73
Symfony polyfill providing PHP 7.3 functions on older PHP versions: array_key_first, array_key_last, hrtime, is_countable, and JsonException. Drop-in compatibility, MIT licensed.
First, verify you need it. This package only pays for itself if some environment you must support runs PHP 7.1 or 7.2. On a modern Laravel app (Laravel 10/11 requires PHP 8.1+, Laravel 8 requires 7.3+), the package is dead weight — and it's very likely already in your composer.lock transitively, because half the Symfony ecosystem depends on the polyfill family.
php -v
composer show | grep polyfill # see which polyfills you already have
composer why symfony/polyfill-php73 # who pulled it in, if anyone
composer show symfony/polyfill-php73 # is it already resolved transitively?
If it's already there, do not add it as a direct dependency — just start calling the functions. Adding a redundant direct require does nothing except clutter composer.json.
Install when you genuinely need to lower your floor:
composer require symfony/polyfill-php73
In a package you publish, put it in require, never require-dev — consumers on old runtimes never install dev dependencies:
"require": {
"php": ">=7.1",
"symfony/polyfill-php73": "^1.20"
}
Where to look first. There is no config file, no service provider, no facade. Open vendor/symfony/polyfill-php73/bootstrap.php — it is roughly a hundred lines and constitutes the entire contract. Every function is wrapped in function_exists(), hrtime() falls back to microtime()-derived values, and JsonException is declared only if the class is absent. Reading that one file answers 90% of questions you'll ever have about this dependency.
First use case — the one that pays for itself immediately. Replacing array-pointer gymnastics in services, form requests, and API adapters:
// Before: mutates the internal pointer, two calls, easy to misread
reset($payload);
$firstKey = key($payload);
// After: native on 7.3+, polyfilled below it, no side effects
$firstKey = array_key_first($payload); // null when $payload is []
$lastKey = array_key_last($payload);
Confirm which implementation is live. The fastest way to know whether the polyfill or PHP itself is serving a function:
php -r 'require "vendor/autoload.php";
var_dump(in_array("array_key_first", get_defined_functions()["user"], true));'
false means PHP 7.3+ is providing it natively (nothing to worry about). true means the polyfill defined it — which also confirms your autoload wiring is correct, which is the thing you actually want to know before you start relying on it.
reset() / end()Reach for array_key_first/array_key_last on plain arrays: decoded API responses, config() blobs, associative rows from DB::select(), validated payloads. Inside Eloquent or Collections, don't convert to an array just to use the polyfilled function — use $collection->keys()->first() or Arr::first() instead. The polyfill exists for array primitives, not as an argument for abandoning collection methods.
private function firstRuleName(array $rules): ?string
{
$key = array_key_first($rules); // null on empty array — always null-check
return $key === null ? null : (string) $key;
}
The JsonException class is the most commonly misused part of this package. Don't sprinkle json_decode() + json_last_error() checks through controllers. Centralize decoding once so the failure mode is identical on every PHP version you deploy:
namespace App\Support;
final class Json
{
public static function decode(string $json, bool $assoc = true, int $depth = 512): mixed
{
if (defined('JSON_THROW_ON_ERROR')) {
return json_decode($json, $assoc, $depth, JSON_THROW_ON_ERROR);
}
$data = json_decode($json, $assoc, $depth);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \JsonException(json_last_error_msg(), json_last_error());
}
return $data;
}
}
Callers then get a uniform contract:
try {
$payload = Json::decode($request->getContent());
} catch (\JsonException $e) {
return response()->json(['error' => 'Malformed JSON'], 400);
}
The polyfill supplies the class, so the catch block never fatals — that's precisely what makes the shared helper possible across PHP versions.
hrtime() for middleware and job instrumentationUse it for monotonic elapsed-time measurement — request duration, queue job timing, a lightweight profiler. Take the integer form (hrtime(true), nanoseconds) for arithmetic; the array form is a [seconds, nanoseconds] tuple and only invites off-by-a-million bugs.
$start = hrtime(true);
// ... work ...
$ms = (hrtime(true) - $start) / 1e6;
Log::channel('perf')->debug('job.duration_ms', ['ms' => round($ms, 3)]);
Wrapped in a terminable middleware or a queue JobProcessed listener, this gives consistent metrics across a mixed-version fleet with no Prometheus client and no extra dependency.
is_countable() at the edgesThe value is at your application boundary — request validation, third-party API adapters, public package APIs — anywhere you accept mixed and want count() without a TypeError:
$total = is_countable($items) ? count($items) : 0;
Inside typed, internal code where you already know the shape, it's noise. Use it where untyped data enters.
If you maintain a Laravel package, the play is clean: require this polyfill, write PHP 7.3-style code unconditionally, keep "php": ">=7.1" in your constraint, and run a CI matrix of 7.2 + your current PHP. Consumers on 7.2 get correct behavior; consumers on 7.3+ pay nothing because the function_exists() guard short-circuits and the native function wins.
Because the polyfill is invisible — identical names, identical signatures, native behavior — removing it later is a composer remove with zero refactoring. Open a ticket on day one: "When the last PHP 7.2 host is retired, drop symfony/polyfill-php73." Without that ticket the dependency becomes permanent sediment nobody dares touch.
Otherwise someone will quietly use 7.3 syntax in a file that only the 8.x job compiles, and you find out on deploy:
# run on the PHP 7.2 job only
- run: composer install --no-dev
- run: php -r 'require "vendor/autoload.php"; exit(function_exists("array_key_first") ? 0 : 1);'
Never hand-roll a shim alongside it. Composer loads dependency autoload_files before your root package's files (typically app/helpers.php). The polyfill's function_exists() check fires first and defines the symbol; your shim then declares it again and you get a fatal Cannot redeclare array_key_first(). Audit before installing: grep -rn "function array_key_first\|function is_countable\|class JsonException" app/.
JsonException is a class, not a behavior. The polyfill cannot change json_decode()'s engine-level semantics — JSON_THROW_ON_ERROR is a constant plus C-level behavior baked into PHP 7.3. Always gate on defined('JSON_THROW_ON_ERROR'), as the Json::decode helper above does. Catching \JsonException and then passing JSON_THROW_ON_ERROR to json_decode() on PHP 7.2 gives you an undefined-constant error, not an exception.
Empty arrays return null, not a failure. array_key_first([]) === null on both native and polyfilled implementations. Null-check; never assume a key exists because the function returned without error. Also note the key can legitimately be 0 or "" — use === null, not a falsy check.
hrtime() is not a clock. It's monotonic, so it is excellent for deltas and useless for timestamps — use now() or microtime(true) for wall time. The fallback implementation on old runtimes has coarser resolution than native, so never compare absolute nanosecond values across a mixed-version fleet; only compare durations measured within one process.
Two different version knobs, don't conflate them. config.platform.php in composer.json should reflect production (e.g. "7.2.34") so dependency resolution won't pull in libraries requiring 7.3+. PHPStan/Psalm are separate — point phpVersion: 70300 (or ship a stub file) at the language features you're using, or the analyzer flags array_key_first as undefined. These are not the same number and both need to be right.
Autoload cost is trivial but non-zero. autoload_files executes on every request before Laravel boots — including php artisan and queue workers. Four function_exists() checks are nanoseconds. The real argument is against stacking five polyfills "just in case," not against this one.
Don't hard-pin to the latest tag. The recent release history is literally "no significant changes" repeated; the API has been frozen for years. A ^1.20 caret constraint is safer than a hard pin because it picks up the rare security fix without churn. Hard-pin only under a compliance mandate, and add Renovate/Dependabot so it doesn't rot.
The package itself requires PHP >= 7.1. It will not rescue a 5.6 or 7.0 application. Below 7.1 you need symfony/polyfill-php71/php72, or an upgrade — this is the wrong tool.
Debugging "polyfill or native?" — the get_defined_functions() snippet in Getting Started is the fastest answer. ['internal'] means native, ['user'] means the polyfill. That one command resolves most "works on my machine, not in the container" mysteries before you start reading stack traces.
Know exactly where the boundary is. This polyfill covers five things and nothing else. It does not give you trailing commas in function calls, list() reference assignment, flexible heredoc indentation, or typed properties. Those are parser and engine features that no autoloaded PHP file can backport. If your code uses PHP 7.3 syntax, the polyfill won't save you — you'll get a parse error on 7.2 regardless of this dependency being installed.
How can I help you explore Laravel packages today?