Der Webserver legt eine Datei im gemeinsamen Verzeichnis ab, und das nächtliche Wartungsskript kommt nicht heran. Beide Systembenutzer arbeiten auf demselben Pfad, trotzdem steht im Protokoll ein Zugriffsfehler. PHP chgrp() löst genau diesen Fall, weil es nicht am Recht dreht, sondern am Adressaten des Rechts. Wie Rechte gesetzt werden, steht im Tutorial zu chmod, und ob ein gesetztes Recht am Ende greift, klärt die Prüfseite mit is_executable(). Hier geht es um die zweite Hälfte derselben Regel: für wen ein gesetztes Recht überhaupt gilt.
Eine Datei trägt zwei Etiketten: den Eigentümer auf der einen Seite, die Gruppe auf der anderen. Das zweite Etikett ist das, um das es hier geht.
PHP chgrp(), chown() und chmod(): was sich jeweils ändert
Jede Datei unter Linux und macOS hängt an zwei Angaben, die gern verwechselt werden. Die Rechtebits sagen, was erlaubt ist: lesen, schreiben, starten. Eigentümer und Gruppe sagen, für wen das jeweils gilt. Ein Recht ohne passenden Adressaten ist wirkungslos, und genau an dieser Stelle setzt der Aufruf an. Er verschiebt die Datei in eine andere Gruppe und lässt die Rechtebits unangetastet.
Daraus folgt eine Empfehlung, die dem üblichen Reflex widerspricht. Wer bei einem Zugriffsproblem zu chmod 0777 greift, öffnet die Datei für jeden Prozess auf der Maschine, auch für den fremden Kundenaccount nebenan. Eine gemeinsame Gruppe zusammen mit chmod 0770 öffnet sie für genau zwei Systembenutzer. chgrp() ist deshalb oft das präzisere Mittel und nicht nur das seltenere.
| Funktion | Ändert | Typische Frage dahinter |
chmod() | die Rechtebits, also Lesen, Schreiben und Starten je Klasse | Was ist erlaubt? |
chown() | den Eigentümer, also den einen zuständigen Systembenutzer | Wem gehört die Datei? |
chgrp() | die Gruppe, also den Kreis der mitlesenden Benutzer | Wer darf noch dazu? |
Syntax und Parameter: $filename, $group und der Rückgabewert
Zwei Parameter, ein Wahrheitswert als Ergebnis. Die englische Fassung mitsamt der beiden Nachbarfunktionen sieht so aus:
chgrp(string $filename, string|int $group): bool
/* filename - path to the file to change
group - group name or numeric group id
return - true on success, false on failure
Attempts to change the group of the file
filename to group. Only the superuser may
change the group arbitrarily; other users
may change the group of a file to any group
of which that user is a member.
Related calls used in this tutorial:
chown(string $filename,
string|int $user): bool
filegroup(string $filename): int|false */
Der zweite Parameter steht ausdrücklich als string|int da: PHP chgrp() nimmt Gruppennamen und Gruppen-ID gleichermaßen entgegen. Der Rückgabetyp ist schlicht bool, einen dritten Zustand für „teilweise gelungen“ gibt es nicht. Das Gegenstück filegroup() liefert int|false, weil es eine Nummer zurückgibt und keine Zusage.
Gruppenname oder Gruppen-ID
Beide Formen sind erlaubt und im Ergebnis nicht zu unterscheiden. Der Name ist lesbar, aber nicht überall gleich: Auf Debian heißt die Webservergruppe www-data, auf Red Hat dagegen apache. Die Nummer ist innerhalb einer Maschine eindeutig, zwischen zwei Maschinen aber keineswegs dieselbe.
<?php
$pfad = '/var/www/app/cache/liste.json';
var_dump(chgrp($pfad, 'www-data'));
/* bool(true) */
clearstatcache(true, $pfad);
var_dump(filegroup($pfad));
/* int(33) */
var_dump(chgrp($pfad, 33));
/* bool(true), dieselbe Wirkung */
Für Skripte, die auf fremden Servern landen, gehört der Name in die Konfiguration und die Auflösung ins Programm. Zuständig sind posix_getgrnam() und posix_getgrgid() aus der POSIX-Erweiterung. Garantiert ist die nicht: Sie kann beim Übersetzen abgeschaltet oder per disable_functions gesperrt sein, und im Windows-Build von PHP 8.4.8 fehlt sie gemessen vollständig. Ein function_exists() vor dem Auflösen kostet eine Zeile.
<?php
function gruppenNummer(string $name): ?int
{
if (!function_exists('posix_getgrnam')) {
return null;
}
$eintrag = posix_getgrnam($name);
return is_array($eintrag)
? $eintrag['gid']
: null;
}
var_dump(gruppenNummer('www-data'));
/* int(33) */
var_dump(gruppenNummer('gibtsnicht'));
/* NULL */
Wer die Gruppe überhaupt setzen darf
In vielen Quellen steht, nur der Systemverwalter dürfe die Gruppe setzen. Das ist die halbe Wahrheit. Er darf jede Datei in jede Gruppe stellen, richtig. Nach der dokumentierten POSIX-Regel darf aber auch ein gewöhnlicher Benutzer wechseln, sofern er die Datei besitzt und der Zielgruppe selbst angehört. Darauf beruht der häufigste Praxisfall: Ausrollbenutzer und Webserverbenutzer kommen in eine gemeinsame Gruppe, und danach reicht ein PHP chgrp() ohne erhöhte Rechte.
Was dagegen nicht hilft, ist ein Schreibrecht an der Datei. Der folgende Lauf fand im Container statt, als Benutzer www-data mit der Nummer 33, auf einer Datei, die root gehört und mit Modus 0666 für jeden beschreibbar ist:
<?php
var_dump(is_writable($pfad));
/* bool(true) */
var_dump(chgrp($pfad, 'www-data'));
/* Warning: chgrp():
Operation not permitted
bool(false) */
Das Schreibrecht an einer Datei und das Recht, ihre Gruppe zu ändern, sind zwei verschiedene Dinge. Wer vorher is_writable() abfragt und daraus schließt, PHP chgrp() werde schon klappen, liegt falsch. Die Datei ließ sich in diesem Lauf beschreiben, ihre Gruppenzugehörigkeit aber nicht antasten.
Nach dem Ausrollen: Besitz und Gruppe geraderücken
Der Entscheidungsweg vor einem Gruppenwechsel lässt sich in ein Bild bringen. Er beantwortet drei Fragen nacheinander, und jede davon kann den Aufruf schon vorher erledigen.
flowchart TD
A[Gruppe setzen] --> B{eigene Datei?}
B -->|nein| C[nur Systemverwalter]
B -->|ja| D{in Zielgruppe?}
D -->|nein| E[Aufruf schlägt fehl]
D -->|ja| F[chgrp aufrufen]
F --> G{Rückgabe wahr?}
G -->|nein| H[Grund protokollieren]
G -->|ja| I[filegroup prüfen]
Im Ausrollskript stehen chown() und PHP chgrp() meist direkt nebeneinander, weil nach dem Entpacken eines Archivs beide Angaben falsch sind. Wichtig ist die Sammelmeldung: Ein Abbruch beim ersten Pfad hinterlässt einen halb umgestellten Baum, und der ist schwerer zu reparieren als eine Fehlerliste am Ende. Wo PHP chgrp() im gesamten Ausrollvorgang steht, zeigt das Tutorial zu den Deployment-Strategien.
<?php
$gruppe = 'www-data';
$fehler = [];
foreach ($pfade as $pfad) {
if (!chown($pfad, 'deploy')) {
$fehler[] = "$pfad: Besitzer";
}
if (!chgrp($pfad, $gruppe)) {
$fehler[] = "$pfad: Gruppe";
}
}
if ($fehler !== []) {
fwrite(STDERR, implode("\n", $fehler));
exit(1);
}
Einen ganzen Verzeichnisbaum umstellen: rekursiv geht es nicht
Der Schalter -R vom gleichnamigen Kommandozeilenwerkzeug hat in PHP keine Entsprechung. PHP chgrp() bearbeitet genau einen Pfad. Wer einen Verzeichnisbaum umstellen will, läuft ihn selbst ab und ruft je Eintrag auf. Zwei Zähler sagen am Ende, wie viele Einträge wirklich umgestellt wurden.
<?php
$basis = '/var/www/app';
$gruppe = 'www-data';
$ok = 0;
$fehl = 0;
$baum = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$basis,
FilesystemIterator::SKIP_DOTS
),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($baum as $eintrag) {
$p = $eintrag->getPathname();
if (chgrp($p, $gruppe)) {
$ok++;
} else {
$fehl++;
}
}
printf("%d gesetzt, %d offen\n", $ok, $fehl);
SELF_FIRST ist hier kein Detail. Damit erscheinen Verzeichnisse vor ihrem Inhalt, und der Durchlauf bearbeitet nie ein Verzeichnis, dessen Einträge schon durch sind.
Nachlesen mit filegroup() und fileowner()
Beide Funktionen liefern eine Zahl, keinen Namen. Im Protokoll steht dann 33 statt www-data, und niemand erinnert sich zwei Wochen später, wofür die 33 stand. Die Auflösung gehört deshalb in die Berichtszeile hinein: posix_getgrgid() für die Gruppe, posix_getpwuid() für den Eigentümer. So zeigt die Protokollzeile nach jedem Lauf, ob PHP chgrp() wirklich durchgekommen ist.
<?php
function bericht(string $pfad, string $soll): string
{
clearstatcache(true, $pfad);
$gid = filegroup($pfad);
$uid = fileowner($pfad);
if ($gid === false || $uid === false) {
return "$pfad: nicht lesbar";
}
$name = (string) $gid;
if (function_exists('posix_getgrgid')) {
$e = posix_getgrgid($gid);
if (is_array($e)) {
$name = $e['name'];
}
}
$besitzer = (string) $uid;
if (function_exists('posix_getpwuid')) {
$e = posix_getpwuid($uid);
if (is_array($e)) {
$besitzer = $e['name'];
}
}
$stand = $name === $soll ? 'ok' : 'abweichend';
return "$pfad besitzer=$besitzer"
. " gruppe=$name $stand";
}
echo bericht('/var/www/app/cache', 'www-data');
/* /var/www/app/cache besitzer=deploy
gruppe=www-data ok */
Das clearstatcache(true, $pfad) ganz oben hat einen Grund: filegroup() gehört zur stat-Familie und wird zwischengespeichert. Eine Messung im Container zeigt, dass PHP diesen Zwischenspeicher für genau einen Pfad hält. Ohne weiteren Dateizugriff blieb der alte Wert 33 stehen, obwohl ein Fremdprozess die Gruppe längst auf 0 gesetzt hatte. Lag ein stat auf einen anderen Pfad dazwischen, kam sofort die 0. Deshalb fällt der Zwischenspeicher nach einem Gruppenwechsel selten auf, und deshalb sollte man sich darauf nicht verlassen. Wie er arbeitet, steht im Tutorial zu fstat() und dem Stat-Cache.
Das setgid-Bit statt des wiederholten Aufrufs
Ein chgrp() nach jedem Schreibvorgang ist eine Einladung zum Vergessen. Eine einzige Stelle, an der die Zeile fehlt, und das Problem ist zurück. Das setgid-Bit dreht die Sache um: Trägt ein Verzeichnis dieses Bit, erben alle darin neu angelegten Dateien und Unterverzeichnisse dessen Gruppe. Gesetzt wird es über chmod() mit vorangestellter Ziffer 2, was die bekannte Oktalschreibweise um eine Stelle erweitert. Welche Rechte ein neu angelegtes Verzeichnis von sich aus mitbekommt, regelt die umask, nachzulesen im Tutorial zu mkdir() und umask.
<?php
$v = '/var/www/gemeinsam';
chgrp($v, 'www-data');
chmod($v, 02775);
clearstatcache(true, $v);
printf("%o\n", fileperms($v) & 07777);
/* 2775 */
file_put_contents($v . '/neu.txt', 'x');
clearstatcache();
var_dump(filegroup($v . '/neu.txt'));
/* int(33), ohne weiteren Aufruf */
Der Lauf oben stammt aus dem Container: Das Verzeichnis steht auf Modus 2775, die anschließend angelegte Datei trägt Gruppe 33. Erledigt ist die Sache damit trotzdem nicht. Das Bit wirkt nur nach vorne. Der Bestand, der vor dem Setzen schon dort lag, bleibt in seiner alten Gruppe.
Wo der Aufruf nichts bewirkt: gemeinsames Hosting und Windows
Auf gemeinsamem Webhosting laufen die Kundenprozesse unter getrennten Systembenutzern, und niemand darf Dateien in fremde Gruppen schieben. Ein falsches Ergebnis ist dort kein Defekt, sondern die Absicht der Plattform. Wer hier Zugriff regeln muss, arbeitet mit den Rechtebits.
Eine zweite Grenze nennt das Handbuch ausdrücklich: PHP chgrp() arbeitet nur auf Pfaden, die im Dateisystem des Servers liegen. Ein Pfad hinter einem Stream-Wrapper, also etwa eine Adresse mit http:// oder ftp://, ist für die Funktion nicht erreichbar. Gemessen im Container kommt dort false und die Warnung chgrp(): Cannot call chgrp() for a non-standard stream. Ein eingehängtes Netzlaufwerk wiederum richtet sich nach den Regeln des einhängenden Systems und nicht nach denen von PHP.
Unter Windows ist die Lage unangenehmer: PHP chgrp() schlägt nicht laut fehl, es schlägt still fehl. Gemessen unter PHP 8.4.8 mit error_reporting(E_ALL) und ohne Unterdrückungsoperator: kein Wort auf dem Bildschirm, kein Eintrag in error_get_last(), nur ein false.
| Gemessen | Linux-Container | Windows 8.4.8 |
| Aufruf gelingt | ja, bool(true) | nie, immer bool(false) |
| Meldung bei Fehlschlag | Warnung mit Grund im Klartext | keine, auch nicht in error_get_last() |
filegroup() | die echte Gruppen-ID, etwa int(33) | konstant int(0) |
| POSIX-Funktionen | vorhanden, sofern nicht gesperrt | fehlen ganz, ebenso lchgrp() |
Wer plattformübergreifend liefert, lässt den Aufruf unter Windows deshalb bewusst aus, statt ihn ins Leere laufen zu lassen. Eine Verzweigung über PHP_OS_FAMILY macht das im Code sichtbar, und die Meldung sagt dem nächsten Leser, dass hier nichts vergessen wurde.
<?php
if (PHP_OS_FAMILY === 'Windows') {
echo "Gruppen gibt es hier nicht\n";
return;
}
if (!chgrp($pfad, 'www-data')) {
throw new RuntimeException("Gruppe: $pfad");
}
Fehlerbehandlung: was gemeldet wird und was nicht
Der Rückgabewert ist die einzige Rückmeldung, auf die in jeder Umgebung Verlass ist. Unter Linux kommt eine Warnung dazu, und die ist nützlich, weil sie den Grund nennt. Zwei davon sind im Container wörtlich festgehalten und wollen unterschiedlich behandelt werden: chgrp(): Unable to find gid for gibtsnicht zeigt auf einen Tippfehler in der Konfiguration, chgrp(): No such file or directory auf einen Pfad, der nicht existiert. Eine dritte Meldung, chgrp(): Operation not permitted, bedeutet dagegen: Der Pfad stimmt, die Gruppe stimmt, nur die Rechte des laufenden Benutzers reichen nicht.
Wer die Warnung nicht im Ausgabestrom haben will, fängt sie mit einem eigenen Fehlerbehandler ab. Der Unterdrückungsoperator wäre hier die schlechtere Wahl, weil er den Text vernichtet, statt ihn umzuleiten.
<?php
set_error_handler(
static function (int $n, string $text): bool {
error_log('chgrp: ' . $text);
return true;
},
E_WARNING
);
$ok = chgrp('/var/www/app/log', 'www-data');
restore_error_handler();
if ($ok === false) {
throw new RuntimeException('Gruppe offen');
}
Im laufenden Betrieb gehört hinter jeden Aufruf eine Auswertung des Rückgabewerts, auch dort, wo sie den Code um zwei Zeilen verlängert.
Fazit
PHP chgrp() beantwortet eine schmale Frage: Zu welchem Kreis von Systembenutzern gehört diese Datei? Die Rechtebits bleiben dabei unberührt, und genau diese Arbeitsteilung macht den Aufruf nützlich. Eine gemeinsame Gruppe mit eng gesetzten Rechten löst das Problem, für das sonst chmod 0777 herhalten muss.
Schreibrecht und Gruppenrecht sind getrennt, gemessen an einer Datei mit Modus 0666, die sich beschreiben, aber nicht umgruppieren ließ. Rekursiv arbeitet die Funktion nicht, den Verzeichnisbaum läuft man selbst ab, und das setgid-Bit erspart alle künftigen Aufrufe im selben Verzeichnis. Unter Windows liefert PHP chgrp() stumm false, weshalb eine Verzweigung über PHP_OS_FAMILY dort ehrlicher ist als ein Aufruf, der nichts tut.