symfony/polyfill-ctype
Provides ctype_* functions (ctype_alpha, ctype_digit, ctype_alnum, and more) for PHP installations missing the ctype extension, ensuring portable character-type checks across environments. Part of the Symfony Polyfill family.
Confirm you already have it before adding anything. In a typical Laravel app symfony/polyfill-ctype arrives transitively through symfony/http-foundation, symfony/validator, or laravel/framework itself:
composer show symfony/polyfill-ctype
composer why symfony/polyfill-ctype
If composer show returns a version, you're done — nothing to configure, register, or publish. Add it as a direct dependency only when your own application code calls ctype_* and you want that contract declared rather than inherited:
composer require symfony/polyfill-ctype
Where to look first.
vendor/symfony/polyfill-ctype/bootstrap.php. That file is the whole package: it's the list of guarded functions (ctype_alnum, ctype_alpha, ctype_digit, ctype_lower, ctype_upper, ctype_space, ctype_xdigit, ctype_punct, ctype_graph, ctype_print, ctype_cntrl, ctype_blank) plus the function_exists() checks that keep it dormant when the native extension is present.First use case — drop the function_exists() guard from a validation rule. The pattern most Laravel developers are carrying around looks like this:
// Before: defensive, noisy, easy to get wrong
if (function_exists('ctype_alnum') && ! ctype_alnum($apiKey)) {
throw ValidationException::withMessages(['key' => 'Invalid API key format.']);
}
With the polyfill installed you delete the guard and write the intent:
// After
if (! ctype_alnum($apiKey)) {
throw ValidationException::withMessages(['key' => 'Invalid API key format.']);
}
That is the entire value proposition: ctype_* behaves identically on every host you deploy to. Prove it once by running that snippet in a container built without the ctype extension — that's your "hello world" for this package.
1. Custom validation rules written against native ctype_*. The highest-value pattern. Write the rule for the native function and let the polyfill carry stripped-down hosts:
class AlphanumericKey implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! ctype_alnum($value)) {
$fail('The :attribute must contain only letters and digits.');
}
}
}
No extension_loaded('ctype') check, no conditional rule registration, no branching in AppServiceProvider.
2. Form request / middleware input gates. Middleware that filters handles, slugs, or opaque identifiers can call ctype_* unconditionally:
$handle = ltrim((string) $request->input('handle'), '@');
abort_unless(ctype_alnum($handle) && strlen($handle) <= 32, 422);
3. Cheap pre-checks before regex. ctype_* is a fast gate before compiling and running a pattern. Useful in slug pipelines when you build slugs manually rather than via Str::slug:
$isClean = ctype_alnum(str_replace(['-', '_'], '', $slug));
$safe = $isClean ? $slug : Str::slug($slug);
4. Artisan commands and importers. CSV imports, log tailers, and seeders iterate over thousands of rows — exactly where you'd reach for ctype_digit to classify a column:
$valid = array_filter($rows, fn ($row) => ctype_digit((string) $row['id']));
This works identically on a developer laptop, a Forge box, and a minimal Alpine CI image.
5. Declare it explicitly when you author a package. If a Laravel package you maintain calls ctype_* internally, require it directly so a consumer's dependency prune can't remove it from underneath you:
"require": {
"php": "^8.1",
"symfony/polyfill-ctype": "^1.34"
}
6. CI matrix as the real contract. A green suite on your laptop exercises native ctype, not the polyfill. Add one job that strips the extension:
- name: Test without ctype
run: |
docker run --rm -v $PWD:/app -w /app php:8.2-cli \
sh -c "php -m | grep -i ctype || echo 'ctype absent'; \
php -d extension= -r 'require \"vendor/autoload.php\"; var_dump(ctype_alnum(\"abc123\") === true);'"
Without this job, the polyfill branch is dead code in your test suite.
7. Pin via your existing dependency automation. Because the package rarely changes, let Dependabot/Renovate bump it alongside the rest of the Symfony set. Minimum useful pin is ^1.34 — that's the release that aligned E_DEPRECATED behavior with PHP 8.1+.
It is a silent no-op when the extension is loaded. The bootstrap does function_exists('ctype_*') and skips definition entirely if native exists. Consequences you need to internalize:
ASCII-only, always. Both native and polyfilled ctype_* operate on single bytes. ctype_alpha('café') returns false on both — the polyfill preserves the limitation rather than fixing it. The dangerous scenario is a team assuming the polyfill makes international input work. It does not. For multilingual validation use mb_* helpers or Laravel's Str methods; that's a product decision, not a package decision.
Empty strings return false. ctype_digit('') is false, not true. This bites when you're checking optional fields — combine with an explicit emptiness check rather than relying on ctype_* alone.
PHP 8.1+ deprecation noise. The polyfill mirrors native behavior by emitting E_DEPRECATED for non-string arguments. In Laravel this usually surfaces as ctype_digit($request->input('id')) where input() returned an int. Cast at the call site:
ctype_digit((string) $request->input('id'));
Do not blanket-suppress E_DEPRECATED in bootstrap/app.php to quiet this — other Symfony components emit deprecations you actually want to see in Flare/Sentry.
Performance delta in hot loops. Native ctype uses a lookup table; the polyfill loops over bytes — roughly 2–3× slower in community benchmarks. Irrelevant for per-request validation, potentially meaningful in bulk importers or rate limiters. Mitigations, in order of preference:
ctype extension on hosts you control (Forge, Vapor, and standard Docker PHP images ship it by default) — the polyfill then steps aside automatically.preg_match('/^[a-z0-9]+$/i', $v) where predictable perf matters more than readability.No extension points, by design. No config, no binding, no way to swap the implementation. If you need locale- or Unicode-aware behavior you are not "extending" this package — you're replacing the calls with mb_* or Str helpers. Don't fight it.
Autoloader registration. The package registers via "autoload": { "files": [...] } in its composer.json. A stale autoloader is the usual cause of "ctype functions don't exist" after a composer.lock change:
composer dump-autoload
Debugging checklist for ctype_* "missing" on a host:
composer dump-autoload — files-autoload entries are the mechanism here.php -r 'var_dump(function_exists("ctype_alnum"));' inside the target container/host, not locally.php -i | grep ctype — if enabled natively, the polyfill is dormant and you're debugging the native extension, not this package.Dependency hygiene. Adding it directly when you never call ctype_* in your own source just muddies composer why audits. Rule of thumb: if composer why symfony/polyfill-ctype resolves to a single parent framework/component package, leave it transitive. If you reference ctype_* in app/, declare it and pin ^1.34.
How can I help you explore Laravel packages today?