Eine Registrierung soll Benutzernamen nur aus Buchstaben und Ziffern annehmen. Der erste Test mit „Müller“ fällt durch, ein Kollege meldet, dass seine Zugangstoken abgewiesen werden, und im Log stehen nach dem Update auf PHP 8.1 lauter Deprecation-Meldungen. Alle drei Fälle hängen an derselben Funktion. Die ganze ctype-Familie stellt das Tutorial zu ctype_xdigit() in einer Tabelle vor. Hier geht es um PHP ctype_alnum() selbst: wofür sie bei Benutzernamen und Token taugt, wo sie scheitert und was sie mit Zahlen, Umlauten und der Locale macht.
Alle Ergebnisse wurden mit PHP 8.4 unter Windows und Linux gemessen. Wo sich die beiden Systeme unterscheiden, steht es dabei.
Was PHP ctype_alnum() prüft
Die Funktion geht einen String Byte für Byte durch und liefert true, wenn jedes Byte ein Buchstabe oder eine Ziffer ist. In der Standardeinstellung heißt das: a bis z, A bis Z und 0 bis 9, also 62 von 256 möglichen Bytes. Leerzeichen, Unterstrich, Bindestrich und Satzzeichen gehören nicht dazu, Umlaute in UTF-8 ebenfalls nicht. Ein leerer String ergibt immer false.
Bei einem Benutzernamen gehört die Funktion in eine kleine Prüfkette. Die Länge kommt zuerst, weil sie am wenigsten kostet:
flowchart TD
A[Eingabe] --> B{ist es ein String?}
B -- nein --> X[abweisen]
B -- ja --> C{Länge 3 bis 20?}
C -- nein --> X
C -- ja --> D{Umlaute erlaubt?}
D -- nein --> E{ctype_alnum}
D -- ja --> F{preg_match mit Unicode}
E -- false --> X
F -- kein Treffer --> X
E -- true --> G[Name annehmen]
F -- Treffer --> G
Syntax und Rückgabewert im Original
Die Signatur nimmt mixed entgegen, gemeint ist aber ein String:
ctype_alnum(mixed $text): bool
/* check for alphanumeric characters
text - the tested string
return - true if every character in the
string is either a letter or a digit,
false otherwise; an empty string
returns false
In the default locale only ASCII counts:
a-z, A-Z and 0-9. Spaces, whitespace and
special characters do not. The function
accepts mixed, but as of PHP 8.1.0 a
non-string argument is deprecated: an int
from -128 to 255 is checked as one ASCII
character, any other int as its digits, so
all numbers from 256 up pass. */
Der Typ mixed ist ein Überbleibsel. Seit PHP 8.1 meldet jeder andere Typ als String eine Deprecation. Das Handbuch zeigt die Funktion mit zwei Beispielwerten, hier in derselben Form:
<?php
foreach (['Bild42', 'Bild 42!'] as $s) {
echo ctype_alnum($s)
? "$s consists of all letters or digits"
: "$s does not consist of all letters"
. ' or digits';
echo PHP_EOL;
}
/* Bild42 consists of all letters or digits
Bild 42! does not consist of all letters or
digits */
Was durchgeht und was nicht: gemessene Beispiele
Die folgende Tabelle zeigt typische Eingaben aus Formularen. Auf beiden Systemen kam dasselbe heraus:
| Eingabe | Ergebnis |
abc123, ABC, 123, Bild42 | true |
| leerer String | false |
abc 123 mit Leerzeichen | false |
abc_123, abc-123 | false |
abc mit Zeilenumbruch oder Nullbyte am Ende | false |
Müller, Straße in UTF-8 | false |
| arabisch-indische Ziffern, der Bruch ½ | false |
Für technische Kennungen ist PHP ctype_alnum() genau richtig: Artikelnummern, Gutscheincodes, kurze Schlüssel. Für Namen von Menschen ist die Funktion zu streng, schon an einem Umlaut scheitert sie.
Benutzernamen und Token prüfen
Ein Benutzername braucht neben dem Zeichenvorrat eine Länge. Weil PHP ctype_alnum() nur ASCII durchlässt, zählt strlen() hier Bytes und Zeichen gleich:
<?php
function name_ok(string $name): bool
{
$laenge = strlen($name);
return $laenge >= 3 && $laenge <= 20
&& ctype_alnum($name);
}
var_dump(name_ok('Bild42'));
/* bool(true) */
var_dump(name_ok('Müller'));
/* bool(false) */
var_dump(name_ok('ab'));
/* bool(false) */
Sollen Leerzeichen, Bindestrich oder Unterstrich erlaubt sein, entfernt man sie vor der Prüfung. Zerlegen muss man den Namen dafür nicht:
<?php
$erlaubt = [' ', '-', '_'];
foreach (['Max Muster', 'max_muster-1', ' '] as $n) {
$rest = str_replace($erlaubt, '', $n);
echo "[$n] ", var_export(ctype_alnum($rest), true);
echo "\n";
}
/* [Max Muster] true
[max_muster-1] true
[ ] false */
Ein Name aus lauter Leerzeichen wird dabei zum leeren String und fällt durch. Folgen wie a--b oder ein führendes Leerzeichen kommen allerdings durch. Wer das ausschließen will, braucht einen regulären Ausdruck.
Bei Token kommt es auf das Alphabet an, mit dem sie erzeugt werden. Ein Token aus bin2hex(random_bytes(16)) besteht nur aus Ziffern und den Buchstaben a bis f und besteht die Prüfung immer. Ein base64url-Token enthält dagegen auch - und _:
<?php
$token = rtrim(strtr(
base64_encode(random_bytes(24)),
'+/',
'-_'
), '=');
var_dump(strlen($token));
/* int(32) */
var_dump(ctype_alnum($token));
/* meistens bool(false), siehe Text */
$ok = preg_match('/\A[A-Za-z0-9_-]{32}\z/', $token);
var_dump($ok);
/* int(1) */
Von 10.000 solchen Token fielen im Test 6414 unter Windows und 6428 unter Linux bei PHP ctype_alnum() durch, also rund 64 Prozent. Rechnerisch sind es 63,8 Prozent, denn jedes der 32 Zeichen ist mit 2 zu 64 einer der beiden Sonderfälle. Prüfung und Erzeugung müssen dasselbe Alphabet verwenden.
Zahlen statt Strings: die Deprecation seit PHP 8.1
Kommt ein Wert als Ganzzahl an, etwa aus einer Datenbankschicht, prüft PHP ctype_alnum() nicht seine Ziffern. Zahlen von -128 bis 255 gelten als Code eines einzelnen Zeichens, negative Werte plus 256. Alle anderen Zahlen werden als Ziffernfolge geprüft. Seit PHP 8.1 meldet jeder solche Aufruf Deprecated: ctype_alnum(): Argument of type int will be interpreted as string in the future:
<?php
var_dump(ctype_alnum(5));
/* bool(false), geprüft wird das Steuerzeichen
mit dem Code 5, nicht die Ziffer */
var_dump(ctype_alnum('5'));
/* bool(true) */
var_dump(ctype_alnum(65));
/* bool(true), das Zeichen A */
var_dump(ctype_alnum(256));
/* bool(true), als Ziffernfolge 256 */
$wert = 5;
var_dump(ctype_alnum((string) $wert));
/* bool(true) */
Jeder der drei Aufrufe mit einer Zahl erzeugte die Deprecation, der mit dem String '5' nicht. 0 ist das Nullbyte, 9 der Tabulator, 32 das Leerzeichen, alle drei ergaben false. Ab 256 kommt dagegen immer true heraus, weil jede positive Ziffernfolge alphanumerisch ist. -129 liegt außerhalb und wird zu "-129", das Minuszeichen macht es false. Floats, null, Wahrheitswerte und Arrays lieferten ohne Fehler false, ebenfalls mit Deprecation. Ein Array aus ?name[]=x richtet so keinen Schaden an, füllt aber das Log. Die Regel ist einfach: Die Funktion bekommt immer einen String, notfalls per Cast auf string, und wer wirklich einen Zeichencode meint, schreibt chr().
Locale und Umlaute: PHP ctype_alnum() zählt Bytes
Was als Buchstabe gilt, legt die Locale fest, genauer die Kategorie LC_CTYPE. Unter Windows startete PHP mit C, im Linux-Container mit C.UTF-8. In beiden Fällen zählte kein Byte über 127. Mit setlocale() lässt sich das ändern, wie das Tutorial zu setlocale() zeigt, aber nur Byte für Byte. Unter Windows funktionierten German_Germany.1252 und de-DE, danach galten 76 der 128 oberen Bytes als alphanumerisch:
<?php
/* nur unter Windows gemessen */
setlocale(LC_CTYPE, 'German_Germany.1252');
var_dump(ctype_alpha('Straße'));
/* bool(true) */
var_dump(ctype_alpha('Größe'));
/* bool(false) */
var_dump(ctype_alnum("M\xFCller"));
/* bool(true), Müller in Latin-1 */
Dass „Straße“ in UTF-8 durchgeht und „Größe“ nicht, ist Zufall. Das ß besteht in UTF-8 aus zwei Bytes, und beide sind in Windows-1252 Buchstaben. Beim ö ist das zweite Byte das Absatzzeichen. Die Funktion prüft also Bytes einer fremden Kodierung und trifft mal richtig, mal nicht. Nur Text in Latin-1 wird zuverlässig erkannt. Im Linux-Container sind außer C, C.utf8 und POSIX keine Locales installiert, dort lieferte setlocale() für alle deutschen Namen false. Für Umlaute ist PHP ctype_alnum() deshalb das falsche Werkzeug, auch mit Locale.
ctype_alpha(): nur Buchstaben
Die Schwesterfunktion lässt keine Ziffern zu und folgt sonst denselben Regeln: leerer String false, Umlaute in UTF-8 false, Zahlen mit derselben Deprecation. Sie passt für Länder- und Sprachcodes oder Namen ohne Sonderzeichen, für Kennungen mit Ziffern bleibt es bei PHP ctype_alnum():
<?php
foreach (['Hallo', 'Hallo1', 'Bild42'] as $s) {
printf(
"%-6s alpha: %d alnum: %d\n",
$s,
ctype_alpha($s),
ctype_alnum($s)
);
}
/* Hallo alpha: 1 alnum: 1
Hallo1 alpha: 0 alnum: 1
Bild42 alpha: 0 alnum: 1 */
PHP ctype_alnum() oder preg_match()?
Viele Beispiele ersetzen die Funktion durch preg_match('/^[a-z0-9]+$/i', $s). Das ist nicht gleichwertig, denn $ passt auch vor einem Zeilenumbruch am Ende. Der Modifikator i macht Groß- und Kleinschreibung gleich, deshalb genügt a-z:
<?php
$s = "abc123\n";
var_dump(ctype_alnum($s));
/* bool(false) */
var_dump(preg_match('/^[a-z0-9]+$/i', $s));
/* int(1), $ passt vor dem Zeilenumbruch */
var_dump(preg_match('/^[a-z0-9]+$/iD', $s));
/* int(0) */
Mit dem Modifikator D oder mit \z statt $ stimmt das Ergebnis wieder. Seine Stärke spielt ein regulärer Ausdruck bei Umlauten und anderen Schriften aus. Die Klasse \p{L} steht für jeden Buchstaben, \p{Nd} für Dezimalziffern und \p{M} für kombinierende Zeichen wie einen nachgestellten Akzent:
<?php
$muster = '/\A[\p{L}\p{M}\p{Nd}]+\z/u';
foreach (['Müller', 'José', 'abc_1', '½'] as $s) {
echo $s, ': ', preg_match($muster, $s), "\n";
}
/* Müller: 1
José: 1
abc_1: 0
½: 0 */
var_dump(preg_match($muster, "M\xFCller"));
/* bool(false), ungültiges UTF-8 */
Mit \p{N} statt \p{Nd} wären auch der Bruch ½ und die hochgestellte Zwei durchgegangen, mit \w zusätzlich der Unterstrich. Ohne \p{M} fiel „Café“ in der zerlegten Unicode-Form durch, in der das é aus e und einem Akzentzeichen besteht. Bei ungültigem UTF-8 liefert preg_match() false statt 0, und preg_last_error_msg() meldet Malformed UTF-8 characters, possibly incorrectly encoded. Geprüft wird deshalb auf === 1. Mehr zu den Mustern steht im Tutorial PHP Regex.
Geschwindigkeit: kurze und lange Strings
Oft heißt es, PHP ctype_alnum() sei schneller als jeder reguläre Ausdruck. Gemessen wurde je eine Million Aufrufe mit zufälligen alphanumerischen Strings, die Zeit für die Schleife selbst ist abgezogen:
| Länge | ctype_alnum() | /^[a-z0-9]+$/iD | /^[\p{L}\p{N}]+$/uD |
| 12 Zeichen | 28 / 25 ms | 48 / 30 ms | 58 / 42 ms |
| 64 Zeichen | 121 / 125 ms | 72 / 53 ms | 107 / 89 ms |
| 256 Zeichen | 436 / 497 ms | 221 / 136 ms | 318 / 271 ms |
Die Werte stehen jeweils für Windows und Linux. Bei kurzen Strings lag PHP ctype_alnum() vorn, ab 64 Zeichen war der reguläre Ausdruck mit JIT deutlich schneller. Für eine einzelne Formularprüfung ist das bedeutungslos, es geht um Nanosekunden. Erst wer Millionen langer Artikelnummern prüft, merkt den Unterschied.
Fazit
PHP ctype_alnum() ist eine schnelle, strenge ASCII-Prüfung für kurze Kennungen. Sie lässt a bis z, A bis Z und Ziffern durch, sonst nichts, und ein leerer String fällt durch. Zusammen mit einer Längenprüfung reicht das für Benutzernamen und Codes. Leerzeichen oder Bindestriche entfernt man vorher mit str_replace(), bei base64url-Token passt ein Muster besser.
Die Funktion bekommt immer einen String, sonst prüft sie Zeichencodes und meldet eine Deprecation. Umlaute erkennt sie nicht verlässlich, auch nicht mit Locale. Sobald Namen mit Umlauten, Zusatzzeichen oder lange Werte ins Spiel kommen, ist preg_match() mit D oder /u die bessere Wahl.