Ein Dateiname stammt aus einem Upload, aus einer Datenbankspalte oder aus der Antwort einer Schnittstelle, und die einzige offene Frage lautet: passt er zu *.pdf? PHP fnmatch beantwortet genau das mit einem true oder einem false. Die Datei muss dazu nirgends liegen, denn die Funktion sieht ausschließlich die Zeichenkette, die man ihr reicht.
Im Bild wandern String und Muster in denselben Vergleich, und heraus kommt eine Ja-Nein-Antwort statt einer Liste. An dieser Stelle setzt der Text an.
Was PHP fnmatch() macht
Die Signatur besteht aus drei Angaben: fnmatch(string $pattern, string $filename, int $flags = 0): bool. Zuerst kommt das Muster, dann der zu prüfende Text, am Ende optional die Flags. Der zweite Parameter trägt zwar den Namen $filename, akzeptiert aber jede beliebige Zeichenkette. Der Name ist historisch gewachsen, weil die Funktion aus der C-Bibliothek stammt und dort auf Dateinamen gemünzt war.
<?php
$name = 'bericht-2026.pdf';
var_dump(fnmatch('*.pdf', $name)); /* bool(true) */
var_dump(fnmatch('bericht-*.pdf', $name)); /* bool(true) */
var_dump(fnmatch('*.docx', $name)); /* bool(false) */
/* Die Datei muss dafuer nicht existieren.
Geprueft wird ausschliesslich der String. */
var_dump(fnmatch('*.pdf', 'gibt-es-nicht.pdf')); /* bool(true) */
Der letzte Aufruf ist der wichtigste im ganzen Tutorial. Es gibt keine Datei mit diesem Namen, und trotzdem steht am Ende ein true. PHP fnmatch führt keinen einzigen Zugriff auf das Dateisystem aus. Wer also wissen will, ob eine Datei tatsächlich vorhanden ist, braucht zusätzlich is_file(). PHP fnmatch beantwortet nur die Frage nach der Form, nie die nach der Existenz.
Der Unterschied zu glob()
Diese Verwechslung kostet die meiste Zeit, deshalb steht sie hier so weit vorn. glob() durchsucht das Dateisystem und liefert die Pfade, die es dort tatsächlich findet. PHP fnmatch prüft eine beliebige Zeichenkette gegen ein Muster und fasst die Festplatte nie an. Daraus folgt schon der sichtbarste Unterschied: glob() gibt ein Array aus, hier steht am Ende ein Wahrheitswert.
<?php
/* glob() geht auf die Platte und liefert echte Pfade */
$vorhanden = glob('uploads/*.jpg');
print_r($vorhanden);
/* Array ( [0] => uploads/urlaub.jpg ) */
/* fnmatch() sieht nur den String, ohne Dateizugriff */
var_dump(fnmatch('uploads/*.jpg', 'uploads/urlaub.jpg'));
/* bool(true) */
var_dump(fnmatch('uploads/*.jpg', 'uploads/gibt-es-nie.jpg'));
/* bool(true), obwohl diese Datei nirgends liegt */
Damit ist die Arbeitsteilung klar. Wer wissen will, welche Dateien es gibt, nimmt PHP glob() zum Suchen und Auflisten von Dateien. Wer einen Namen bewertet, der aus einem Formular, einer Konfigurationsdatei oder einer Schnittstelle kommt, greift zur Musterprüfung. Beide sprechen dieselbe Mustersprache, und genau daraus entsteht das Missverständnis.
Die Muster-Syntax im Überblick
Die Muster, die PHP fnmatch versteht, kennt jeder, der in der Kommandozeile schon einmal ls *.txt getippt hat. Es sind dieselben, nur angewendet auf eine Zeichenkette im Speicher statt auf ein Verzeichnis. Eine Handvoll Bausteine reicht für praktisch alles.
| Zeichen | Bedeutung | Beispiel |
* | Beliebig viele Zeichen, auch gar keines | log* trifft log und logfile.txt |
? | Genau ein Zeichen, nicht null und nicht zwei | bild?.png trifft bild7.png |
[abc] | Genau eines der aufgezählten Zeichen | datei[12].txt trifft datei2.txt |
[a-z] | Ein Zeichen aus einem Bereich | [0-9][0-9].log trifft 07.log |
[!abc] | Kehrt die Auswahl um, schließt die Zeichen also aus | [!_]*.php lässt _intern.php durchfallen |
[^abc] | Kehrt die Auswahl ebenfalls um, geschrieben wie in einem regulären Ausdruck | [^0-9]* lässt 1abc durchfallen |
<?php
/* Der Stern ersetzt beliebig viele Zeichen, auch keins */
var_dump(fnmatch('log*', 'log')); /* bool(true) */
var_dump(fnmatch('log*', 'logfile-2026.txt')); /* bool(true) */
/* Das Fragezeichen ersetzt genau ein Zeichen */
var_dump(fnmatch('bild?.png', 'bild7.png')); /* bool(true) */
var_dump(fnmatch('bild?.png', 'bild12.png')); /* bool(false) */
/* Eckige Klammern erlauben genau eines der Zeichen */
var_dump(fnmatch('datei[12].txt', 'datei2.txt')); /* bool(true) */
var_dump(fnmatch('datei[12].txt', 'datei3.txt')); /* bool(false) */
/* Bereiche arbeiten genauso */
var_dump(fnmatch('[a-z]*.csv', 'export.csv')); /* bool(true) */
var_dump(fnmatch('[0-9][0-9].log', '07.log')); /* bool(true) */
/* Ein Ausrufezeichen kehrt die Auswahl um */
var_dump(fnmatch('[!_]*.php', '_intern.php')); /* bool(false) */
var_dump(fnmatch('[!_]*.php', 'Router.php')); /* bool(true) */
/* Das Dach aus der Regex-Welt wirkt hier genauso */
var_dump(fnmatch('[^_]*.php', '_intern.php')); /* bool(false) */
var_dump(fnmatch('[^_]*.php', 'Router.php')); /* bool(true) */
var_dump(fnmatch('[^0-9]*', '1abc')); /* bool(false) */
/* Die Klammer-Erweiterung der Shell gibt es hier nicht */
var_dump(fnmatch('bild.{jpg,png}', 'bild.jpg')); /* bool(false) */
Der letzte Aufruf markiert eine Grenze. Die aus der Shell vertraute Schreibweise mit geschweiften Klammern versteht glob() über sein Flag GLOB_BRACE, PHP fnmatch dagegen nicht. Mehrere Endungen kommen deshalb als Liste in eine Schleife, wie weiter unten beim Upload. Wichtiger noch ist die zweite Grenze: das sind keine regulären Ausdrücke. In PCRE bezieht sich der Stern immer auf das vorangehende Zeichen, hier steht er für sich allein. Wer aus Gewohnheit .* schreibt, fordert damit einen Punkt gefolgt von beliebig viel und bekommt bei bericht.pdf folgerichtig ein false. Wie die andere Sprache aufgebaut ist, zeigt das Tutorial zu regulären Ausdrücken in PHP.
Genau an dieser Nahtstelle sitzt die negierte Zeichenklasse, und sie wird ständig vertauscht. In der Mustersprache der Shell dreht ein Ausrufezeichen die Auswahl um, also [!abc]. Das Dach in [^abc] stammt aus den regulären Ausdrücken. Nachgemessen versteht PHP fnmatch beide Formen und behandelt sie identisch, unter Windows mit PHP 8.4 genauso wie unter Linux mit PHP 8.4: [!_]*.php und [^_]*.php liefern bei _intern.php beide false und bei Router.php beide true. Vorgesehen ist in der Mustersprache aber nur das Ausrufezeichen, das Dach ist eine Zugabe der darunterliegenden Umsetzung, so wie FNM_CASEFOLD auch. Wer Code auf fremde Server ausliefert, schreibt deshalb [!abc]. Wer eines der beiden Zeichen wörtlich in eine Auswahl aufnehmen will, stellt es nicht an den Anfang: [_^]x trifft gemessen sowohl _x als auch ^x, [^_]x dagegen alles außer _x.
Die vier Flags von PHP fnmatch()
Ohne dritten Parameter arbeitet der Vergleich groß- und kleinschreibungsempfindlich, lässt den Stern über Schrägstriche springen und erfasst auch versteckte Dateien. Jeder dieser Punkte lässt sich einzeln umstellen. Die Tabelle nennt zu jedem Flag einen Aufruf und das gemessene Ergebnis, jeweils mit gesetztem Flag.
| Flag | Wirkung | Beispielaufruf | Ergebnis |
FNM_PATHNAME | Ein Schrägstrich im Text will einen Schrägstrich im Muster sehen | 'src/*.php' gegen 'src/Admin/Controller.php' | false |
FNM_PERIOD | Ein führender Punkt will einen echten Punkt im Muster sehen | '*' gegen '.env' | false |
FNM_CASEFOLD | Groß- und Kleinschreibung bleibt unbeachtet | '*.PDF' gegen 'brief.pdf' | true |
FNM_NOESCAPE | Der Backslash maskiert nichts mehr und gilt als normales Zeichen | 'a\*b' gegen 'a*b' | false |
<?php
$pfad = 'src/Admin/Controller.php';
/* Ohne Flag springt der Stern ueber Schraegstriche */
var_dump(fnmatch('src/*.php', $pfad)); /* bool(true) */
/* Mit FNM_PATHNAME bleibt er in einer Ebene */
var_dump(fnmatch('src/*.php', $pfad, FNM_PATHNAME)); /* bool(false) */
var_dump(fnmatch('src/*/*.php', $pfad, FNM_PATHNAME)); /* bool(true) */
/* FNM_PERIOD haelt versteckte Dateien vom Stern fern */
var_dump(fnmatch('*', '.env')); /* bool(true) */
var_dump(fnmatch('*', '.env', FNM_PERIOD)); /* bool(false) */
var_dump(fnmatch('.*', '.env', FNM_PERIOD)); /* bool(true) */
/* FNM_CASEFOLD uebergeht Gross- und Kleinschreibung.
Achtung: das ist eine GNU-Erweiterung. */
var_dump(fnmatch('*.PDF', 'brief.pdf')); /* bool(false) */
var_dump(fnmatch('*.PDF', 'brief.pdf', FNM_CASEFOLD)); /* bool(true) */
/* Ohne FNM_NOESCAPE maskiert der Backslash das folgende
Zeichen, der Stern wird dadurch woertlich genommen */
var_dump(fnmatch('a\*b', 'a*b')); /* bool(true) */
var_dump(fnmatch('a\*b', 'a*b', FNM_NOESCAPE)); /* bool(false) */
/* Flags lassen sich mit dem Pipe-Zeichen verbinden */
$flags = FNM_PATHNAME | FNM_CASEFOLD;
var_dump(fnmatch('SRC/*/*.PHP', $pfad, $flags)); /* bool(true) */
Der praktisch wichtigste Eintrag ist der erste. Ohne FNM_PATHNAME überspringt der Stern jede Verzeichnisgrenze, und das Muster trifft mehr, als jemand gewollt hat. Solche Fehler melden sich nicht, sie liefern still ein true. Beim zweiten Eintrag geht es um versteckte Dateien: ein blankes * zieht .env und .htaccess selbstverständlich mit hinein, solange FNM_PERIOD fehlt. Zu FNM_CASEFOLD gehört eine Einschränkung: es ist eine GNU-Erweiterung und kein Teil des POSIX-Standards, auf ungewöhnlichen Systemen kann das Verhalten abweichen. Alle Aufrufe oben liefern unter Windows dieselben Werte wie unter Linux, PHP fnmatch verhält sich bei den Flags also auf beiden Systemen gleich.
Arrays nach einem Muster filtern
Selten steht nur ein einzelner Name zur Debatte, meistens liegt eine ganze Liste vor. Eine Pfeilfunktion in array_filter() genügt dafür, und PHP fnmatch liefert genau das bool, das der Filter erwartet.
<?php
$dateien = [
'rechnung-2025-01.pdf',
'rechnung-2026-01.pdf',
'rechnung-2026-02.pdf',
'notizen.txt',
];
$treffer = array_filter(
$dateien,
static fn (string $name): bool
=> fnmatch('rechnung-2026-*.pdf', $name)
);
print_r(array_values($treffer));
/* Array (
[0] => rechnung-2026-01.pdf
[1] => rechnung-2026-02.pdf
) */
Der Filter behält die ursprünglichen Schlüssel, im Beispiel also 1 und 2. Wer die Liste danach ausgibt oder als JSON verschickt, setzt sie mit array_values() neu, sonst entsteht ein Objekt statt eines Arrays. Weitere Muster für diesen Aufruf sammelt das Tutorial zu PHP array_filter(). Wer statt eines Musters einen regulären Ausdruck braucht, findet in PHP preg_grep() das direkte Gegenstück.
Praxis: eine eigene Ignorier-Liste bauen
Ein Build-Skript soll bestimmte Pfade beim Kopieren auslassen, und die Ausschlussliste soll lesbar bleiben wie eine .gitignore. Der Kern besteht aus einer Schleife, die beim ersten Treffer abbricht.
<?php
$ignorieren = [
'*.log',
'*.tmp',
'cache/*',
'node_modules/*',
];
function istIgnoriert(string $pfad, array $muster): bool
{
foreach ($muster as $eintrag) {
if (fnmatch($eintrag, $pfad)) {
return true; /* erster Treffer genuegt */
}
}
return false;
}
var_dump(istIgnoriert('cache/seite.html', $ignorieren)); /* bool(true) */
var_dump(istIgnoriert('fehler.log', $ignorieren)); /* bool(true) */
var_dump(istIgnoriert('src/Router.php', $ignorieren)); /* bool(false) */
Hier ist das fehlende Flag ausnahmsweise erwünscht, denn cache/* soll auch tiefer liegende Dateien erfassen. Bei Routen kippt das ins Gegenteil, und dort wird PHP fnmatch ohne Flag zur Fehlerquelle. Ein Muster wie admin/* trifft ohne FNM_PATHNAME ebenso admin/users/edit, und eine Rechteprüfung, die eigentlich nur die erste Ebene meint, greift plötzlich viel weiter. Mit gesetztem Flag trennen sich admin/* und admin/*/* sauber, und eine kleine Regeltabelle wird damit vorhersehbar.
<?php
$pfad = 'admin/users/edit';
/* Ohne Flag greift die Regel zu weit */
var_dump(fnmatch('admin/*', $pfad)); /* bool(true) */
/* Mit Flag trifft sie nur die erste Ebene */
var_dump(fnmatch('admin/*', $pfad, FNM_PATHNAME)); /* bool(false) */
var_dump(fnmatch('admin/*/*', $pfad, FNM_PATHNAME)); /* bool(true) */
$regeln = [
'admin/*' => 'nur Redaktion',
'admin/*/*' => 'nur Leitung',
'api/v?/*' => 'Token noetig',
];
foreach ($regeln as $muster => $bedingung) {
if (fnmatch($muster, $pfad, FNM_PATHNAME)) {
echo $bedingung; /* nur Leitung */
break;
}
}
Praxis: Dateiendungen beim Upload prüfen
Der Klassiker unter den Anwendungsfällen: eine Whitelist erlaubter Endungen, gegen die der hochgeladene Name geprüft wird. Ein strtolower() vorweg spart die doppelten Einträge für .JPG und .jpg.
<?php
$erlaubt = ['*.jpg', '*.jpeg', '*.png', '*.webp'];
function endungErlaubt(string $name, array $muster): bool
{
$name = strtolower($name);
foreach ($muster as $eintrag) {
if (fnmatch($eintrag, $name)) {
return true;
}
}
return false;
}
var_dump(endungErlaubt('Urlaub.JPG', $erlaubt)); /* bool(true) */
var_dump(endungErlaubt('schad.php', $erlaubt)); /* bool(false) */
/* Und genau hier endet die Aussagekraft des Namens: */
var_dump(endungErlaubt('schad.php.jpg', $erlaubt)); /* bool(true) */
Die dritte Zeile ist der Grund, warum dieser Abschnitt weitergeht. Der Dateiname kommt vom Browser des Besuchers und sagt über den Inhalt nichts aus. Eine Endungsprüfung mit PHP fnmatch ist ein billiger Vorfilter, der offensichtlichen Unsinn abweist, und mehr ist sie nicht. Was wirklich in der Datei steht, klärt erst ein Blick auf die ersten Bytes.
<?php
/* Schritt 1: billiger Vorfilter auf den Namen */
if (!endungErlaubt($_FILES['bild']['name'], $erlaubt)) {
exit('Dateityp nicht erlaubt');
}
/* Schritt 2: der eigentliche Schutz, hier zaehlt der Inhalt */
$finfo = new finfo(FILEINFO_MIME_TYPE);
$typ = $finfo->file($_FILES['bild']['tmp_name']);
/* Eine getarnte PHP-Datei meldet sich hier als text/x-php */
$erlaubteTypen = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($typ, $erlaubteTypen, true)) {
exit('Der Inhalt passt nicht zur Endung');
}
Wie die Erkennung über die Magic-Bytes im Einzelnen arbeitet, steht im Tutorial zu PHP finfo und dem MIME-Type, den kompletten Ablauf beschreibt PHP File-Upload. Dazu gehört ein Zielverzeichnis ohne Ausführungsrechte. Erst diese drei Schritte zusammen ergeben einen Schutz.
Die Herkunft des Musters entscheidet, ob glob(), fnmatch() oder preg_match() zuständig ist. Die Flags kommen ins Spiel, sobald Pfade oder Großschreibung mitreden.
flowchart TD
A[Text gegen Muster pruefen] --> B{Quelle?}
B -->|Dateien auf der Platte| C[glob nutzen]
B -->|String im Speicher| D[fnmatch nutzen]
D --> E{Muster oder Regex?}
E -->|Stern, Fragezeichen| F[fnmatch]
E -->|Gruppen, Rueckbezuege| G[preg_match]
F --> H{Pfad mit Schraegstrich?}
H -->|Ja| I[FNM_PATHNAME setzen]
H -->|Nein| J{Gross und Klein egal?}
J -->|Ja| K[FNM_CASEFOLD setzen]
J -->|Nein| L[Ohne Flag aufrufen]
I --> M[function_exists pruefen]
K --> M
L --> M
PHP fnmatch() oder preg_match()?
Wer viel mit regulären Ausdrücken arbeitet, hält PHP fnmatch schnell für deren schwächere Variante. Das trifft die Sache nicht. Beide lösen verschiedene Aufgaben, und die Entscheidung hängt weniger am Code als an der Herkunft des Musters.
| Kriterium | fnmatch() | preg_match() |
| Syntax | Vier Zeichen, keine Begrenzer | Voller PCRE-Umfang samt Begrenzern und Modifikatoren |
| Aufwand für Anwender | In zwei Minuten erklärt | Setzt Schulung voraus |
| Gruppen und Rückbezüge | Nicht vorhanden | Kernbestandteil |
| Rückgabe | Ein Wahrheitswert | Trefferzahl, dazu die Teiltreffer per Referenz |
| Typischer Einsatz | Muster aus Konfiguration oder Eingabemaske | Zerlegen und Auslesen von Zeichenketten |
Daraus wird eine Regel, die im Code-Review trägt. Sollen Anwender das Muster selbst hinterlegen, etwa in einer Ausschlussliste oder in einem Einstellungsfeld, gehört die Aufgabe zu PHP fnmatch, denn niemand tippt dort freiwillig einen regulären Ausdruck. Sobald aber Gruppen, Alternativen, Quantoren oder Rückbezüge gebraucht werden oder Teile des Treffers weiterverarbeitet werden sollen, führt kein Weg an PHP preg_match() vorbei.
Verfügbarkeit: Windows und function_exists()
Ein Skript arbeitet lokal einwandfrei und bricht auf dem Kundenserver mit der Meldung "Call to undefined function fnmatch()" ab. Der Grund ist keine Fehlkonfiguration. Die Funktion war unter Windows bis einschließlich PHP 7.3 schlicht nicht vorhanden und steht dort erst ab PHP 7.4 bereit. Auf aktuellen Installationen ist der Fall damit erledigt, in gewachsenen Kundenprojekten aber nicht. Wer Code ausliefert, der auf fremden Servern landet, fragt deshalb vorher nach.
<?php
if (!function_exists('fnmatch')) {
/**
* Ersatz fuer Umgebungen ohne fnmatch().
* Deckt Stern, Fragezeichen, Zeichenklassen und Bereiche ab,
* nicht aber die Flag-Semantik.
*/
function fnmatch(string $muster, string $text, int $flags = 0): bool
{
$roh = preg_quote($muster, '#');
/* preg_quote maskiert auch Bindestrich und Ausrufezeichen.
Ohne die letzten beiden Eintraege trifft [a-z] nur die
drei Zeichen a, Bindestrich und z. */
$regex = strtr($roh, [
'\*' => '.*',
'\?' => '.',
'\[' => '[',
'\]' => ']',
'\-' => '-',
'\!' => '!',
]);
/* Beide Negations-Schreibweisen auf die von PCRE bringen.
Ohne den zweiten Eintrag bliebe das Dach maskiert,
und [^_] kaeme genau verkehrt herum heraus. */
$regex = str_replace(['[!', '[\^'], '[^', $regex);
return (bool) preg_match('#^' . $regex . '$#', $text);
}
}
var_dump(fnmatch('*.txt', 'liesmich.txt')); /* bool(true) */
var_dump(fnmatch('bild?.png', 'bild12.png')); /* bool(false) */
var_dump(fnmatch('[a-z]*.csv', 'export.csv')); /* bool(true) */
var_dump(fnmatch('[!_]*.php', '_intern.php')); /* bool(false) */
var_dump(fnmatch('[^_]*.php', '_intern.php')); /* bool(false) */
Drei Dinge sind an diesem Ersatz bemerkenswert. Erstens steht die Funktionsdefinition innerhalb der Bedingung, sonst kollidiert sie auf jedem gesunden Server mit der eingebauten. Zweitens trägt die Zeile zu den beiden Negations-Schreibweisen die ganze Last: ohne sie liefert der Ersatz für [^_]*.php und _intern.php ein true, also das Gegenteil der eingebauten Funktion, weil preg_quote() das Dach maskiert und PCRE es danach wörtlich nimmt. Drittens bildet er die Flags nicht nach, ein FNM_PATHNAME bleibt also wirkungslos. Für einen Notbetrieb reicht das. Wo PHP fnmatch tatsächlich fehlt, ist die Anhebung der PHP-Version die ehrlichere Antwort.
Stolperfallen, die still danebengehen
Ein Muster, das danebengreift, sieht genauso aus wie eines, das trifft. PHP fnmatch gibt in beiden Fällen ein sauberes true oder false zurück und schweigt ansonsten. Vier Verwechslungen leben von diesem Schweigen.
Ein Treffer wird als Existenznachweis gelesen Geprüft wurde die Form des Namens, nicht der Inhalt eines Verzeichnisses. Wer beides braucht, stellt is_file() daneben oder wechselt gleich zu glob().
Der Stern trifft auch die leere Zeichenkette Das Muster datei* passt ebenso auf datei ohne jede Endung. Soll mindestens ein Zeichen folgen, gehört ein Fragezeichen davor.
Ein Regex-Muster wurde eins zu eins übernommen Ein .* aus PCRE verlangt hier einen echten Punkt am Anfang. Solche Muster funktionieren manchmal zufällig und scheitern beim nächsten Datensatz.
Anwender dürfen eigene Muster hinterlegen Ein einzelner Stern deckt alles ab. Geprüft wird deshalb nicht das eingegebene Muster, sondern die Menge, die es am Ende trifft.
Fazit
PHP fnmatch beantwortet eine einzige Frage: passt diese Zeichenkette zu diesem Muster? Das Dateisystem bleibt dabei unangetastet, und darin liegt der ganze Unterschied zu glob(), das existierende Pfade sucht und ein Array zurückgibt. Die Mustersprache umfasst vier Zeichen, sie stammt aus der Shell und hat mit regulären Ausdrücken nichts zu tun.
Vier Punkte lohnen den zweiten Blick. Bei Pfaden und Routen gehört FNM_PATHNAME gesetzt, sonst springt der Stern über jede Verzeichnisgrenze. Versteckte Dateien fallen erst mit FNM_PERIOD aus einem *-Muster heraus. Und eine Endungsprüfung beim Upload bleibt ein Vorfilter, dem der MIME-Test folgen muss. Wer Code auf fremde Server ausliefert, stellt ein function_exists('fnmatch') davor. Mit diesen vier Handgriffen ist PHP fnmatch ein verlässliches und vor allem gut lesbares Werkzeug.