DE
Jetzt starten

Hilfe · Website

Best Practices für PHP-Websites

Zehn Regeln, mit denen eine PHP-Website bei uns sicher, robust und leicht zu pflegen bleibt – jede mit einem Beispiel. Die drei wichtigsten: Geheimnisse gehören in den Datenbereich und nie in deine Website-Dateien oder in den Chat. Den Pfad dorthin liest du aus PUBLISHING_STORAGE. Und Fehler suchst du in der Vorschau, denn nur sie zeigt sie an.

Die Grundlagen – welche Verzeichnisse es gibt, was der Reiter «Daten» kann, welche Grenzen gelten – stehen in PHP und der Datenbereich. Dein Assistent kennt diese Regeln ebenfalls und baut danach.

1. Geheimnisse gehören in den Datenbereich

API-Schlüssel, SMTP-Passwörter und Zugangsdaten stehen in einer Datei im Datenbereich, die du im Reiter «Daten» hochlädst. Nicht in deinen Website-Dateien, und nicht im Chat.

  • Nicht in den Website-Dateien: Alles dort wird veröffentlicht, und jede Änderung wird als Version aufbewahrt. Ein Schlüssel, der einmal dort stand, steht auch nach dem Löschen noch in der Geschichte.
  • Nicht im Chat: Was du dort schreibst, läuft durch ein Sprachmodell. Dein Assistent sagt dir, welche Datei mit welchen Feldern sein Code erwartet – die Werte trägst du selbst ein.

Nimm für die Konfiguration JSON und nicht PHP. Eine config.php im Datenbereich hält der PHP-Cache fest: Ersetzt du sie, wirkt die neue erst nach der nächsten Ruhepause der Website. Eine JSON-Datei wird bei jedem Aufruf frisch gelesen.

{
  "smtp": { "host": "mail.anbieter.ch", "user": "website@deine-domain.ch", "passwort": "…" },
  "zahlung": { "schluessel": "sk_live_…" }
}
<?php
function konfig(): array
{
    static $konfig = null;

    if ($konfig === null) {
        $datei = datenpfad('config.json'); // datenpfad() steht in Regel 2

        if (!is_file($datei)) {
            throw new RuntimeException('config.json fehlt im Datenbereich.');
        }

        $konfig = json_decode(file_get_contents($datei), true, 512, JSON_THROW_ON_ERROR);
    }

    return $konfig;
}

2. Den Pfad aus der Umgebung lesen

Schreib /var/www/storage nicht an zwanzig Stellen hin. Eine kleine Funktion genügt, und auf deinem eigenen Rechner setzt du PUBLISHING_STORAGE auf einen Ordner dort – derselbe Code läuft dann an beiden Orten.

<?php
function datenpfad(string $datei = ''): string
{
    $basis = rtrim(getenv('PUBLISHING_STORAGE') ?: '/var/www/storage', '/');

    return $datei === '' ? $basis : $basis . '/' . ltrim($datei, '/');
}

getenv() und nicht $_ENV – das bleibt hier leer.

3. Vorschau und Live über die Daten trennen, nicht über den Code

Die Vorschau hat ihren eigenen Datenbereich. Leg dort eine config.json mit Test-Schlüsseln ab – etwa dem Testmodus deiner Zahlungs-API – und live eine mit den echten. Derselbe Code nimmt dann in der Vorschau den Test und live den Ernstfall, ohne eine einzige Abfrage.

Wenn du es doch einmal im Code wissen musst:

$vorschau = getenv('PUBLISHING_VORSCHAU') === '1';

4. Daten in SQLite statt in einem Datenbankserver

Einen Datenbankserver gibt es bei uns nicht, und einen fremden erreichst du nicht (MySQL braucht Port 3306, offen sind nur 80, 443, 587 und 465). Für Anfragen, Bestellungen und Anmeldungen genügt SQLite: eine einzige Datei im Datenbereich, mit allem, was man von einer Datenbank erwartet.

<?php
$db = new PDO('sqlite:' . datenpfad('daten.sqlite'));
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Warten statt scheitern, wenn gerade jemand anderes schreibt.
$db->exec('PRAGMA busy_timeout = 5000');

$db->exec('CREATE TABLE IF NOT EXISTS anfragen (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL,
    nachricht TEXT NOT NULL,
    eingang TEXT NOT NULL
)');

$neu = $db->prepare('INSERT INTO anfragen (name, email, nachricht, eingang) VALUES (?, ?, ?, ?)');
$neu->execute([$name, $email, $nachricht, date('c')]);
  • Immer mit Platzhaltern (?), nie mit zusammengesetztem Text. Das ist der Schutz gegen SQL-Injection.
  • Zum Sichern genügt die eine Datei – herunterladen im Reiter «Daten», am besten zu einer ruhigen Zeit.

Für sehr wenig Daten – eine Liste von zwanzig Einträgen – reicht auch eine JSON-Datei. Dann gilt Regel 5.

5. Gleichzeitig schreiben: sperren und ersetzen

Bis zu acht Anfragen laufen gleichzeitig. Zwei, die dieselbe Datei schreiben, zerstören sie – und eine halb geschriebene JSON-Datei legt die ganze Website lahm.

Anhängen mit Sperre:

file_put_contents(datenpfad('anmeldungen.csv'), $zeile . "\n", FILE_APPEND | LOCK_EX);

Ganz ersetzen über eine Zwischendatei im selben Ordner. Das Umbenennen geschieht auf einen Schlag: Wer liest, sieht entweder die alte oder die neue Datei, nie eine halbe.

function speichere(string $datei, string $inhalt): void
{
    $ziel = datenpfad($datei);
    $halb = $ziel . '.' . bin2hex(random_bytes(4)) . '.tmp';

    file_put_contents($halb, $inhalt, LOCK_EX);
    rename($halb, $ziel);
}

Lesen, ändern, zurückschreiben – etwa einen Zähler – braucht eine Sperre um alles herum:

$sperre = fopen(datenpfad('zaehler.lock'), 'c');
flock($sperre, LOCK_EX);

$stand = (int) @file_get_contents(datenpfad('zaehler.txt'));
speichere('zaehler.txt', (string) ($stand + 1));

flock($sperre, LOCK_UN);
fclose($sperre);

Wird es mehr als das, nimm SQLite – dort ist das alles schon gelöst.

6. Mail über SMTP, nicht über mail()

mail() verschickt bei uns nichts und gibt false zurück. Für ein Kontaktformular nimmst du SMTP bei einem Mailanbieter – das ist ohnehin der bessere Weg: Der Anbieter kümmert sich um die Absenderprüfung (SPF, DKIM), und deine Mails landen nicht im Spam.

  • Port 587 mit STARTTLS oder 465 mit TLS. Port 25 ist gesperrt.
  • Zugangsdaten in der config.json im Datenbereich (Regel 1).
  • Absender ist deine eigene Adresse, die Adresse des Besuchers kommt in «Antwort an». Wer die Besucheradresse als Absender einträgt, fälscht einen Absender – und genau das sortieren Mailprogramme aus.
  • Speichere die Anfrage zusätzlich (Regel 4). Fällt der Mailanbieter einmal aus, ist nichts verloren.

Mit PHPMailer:

<?php
use PHPMailer\PHPMailer\PHPMailer;

require __DIR__ . '/vendor/autoload.php';

$smtp = konfig()['smtp'];

$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $smtp['host'];
$mail->Port = 587;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->SMTPAuth = true;
$mail->Username = $smtp['user'];
$mail->Password = $smtp['passwort'];
$mail->CharSet = 'UTF-8';

$mail->setFrom('website@deine-domain.ch', 'Website');
$mail->addReplyTo($email, $name);
$mail->addAddress('kontakt@deine-domain.ch');
$mail->Subject = 'Neue Anfrage über die Website';
$mail->Body = $nachricht;

$mail->send();

In der Vorschau verschickst du so echte Mails, wenn dort dieselben Zugangsdaten liegen. Leg im Vorschau-Bereich darum eine Konfiguration mit einer Testadresse als Empfänger ab (Regel 3).

7. Uploads von Besuchern sicher annehmen

Eine hochgeladene Datei ist fremde Eingabe – ihr Name, ihre Grösse und ihr behaupteter Typ ebenso.

<?php
$datei = $_FILES['beleg'] ?? null;

if (!$datei || $datei['error'] !== UPLOAD_ERR_OK) {
    exit('Der Upload hat nicht geklappt.');
}

if ($datei['size'] > 10 * 1024 * 1024) {
    exit('Die Datei ist grösser als 10 MB.');
}

// Den Typ aus dem Inhalt bestimmen, nicht aus dem Namen.
$typ = (new finfo(FILEINFO_MIME_TYPE))->file($datei['tmp_name']);
$erlaubt = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf'];

if (!isset($erlaubt[$typ])) {
    exit('Erlaubt sind JPG, PNG und PDF.');
}

// Ein eigener, zufälliger Name – nie der des Besuchers.
$name = bin2hex(random_bytes(16)) . '.' . $erlaubt[$typ];

if (!is_dir(datenpfad('uploads'))) {
    mkdir(datenpfad('uploads'), 0770, true);
}

move_uploaded_file($datei['tmp_name'], datenpfad('uploads/' . $name));

Ausliefern geht dann über ein eigenes Skript, das zuerst prüft, wer die Datei sehen darf – und den Namen streng kontrolliert, bevor es ihn benutzt:

if (!preg_match('/^[a-f0-9]{32}\.(jpg|png|pdf)$/', $name)) {
    http_response_code(404);
    exit;
}

header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="beleg.' . pathinfo($name, PATHINFO_EXTENSION) . '"');
header('X-Content-Type-Options: nosniff');
readfile(datenpfad('uploads/' . $name));

Nenne die Grenze im Formular selbst – sonst sieht ein Besucher mit einer zu grossen Datei nur eine Fehlerseite. Die äusserste Grenze bei uns sind 100 MB je Datei.

8. Fehler finden

In der Vorschau zeigt PHP jeden Fehler an, auch Hinweise auf veraltete Funktionen. Live zeigt es keinen – Besucher sollen keine Pfade und Abfragen sehen. Ein Protokoll der veröffentlichten Website kannst du nicht einsehen. Brauchst du eines, schreib es selbst in den Datenbereich, mit einer Grenze, damit es nicht endlos wächst:

function protokoll(string $zeile): void
{
    $datei = datenpfad('protokoll.log');

    // Ab 1 MB beginnt eine neue Datei; die vorherige bleibt als .1 liegen.
    if (is_file($datei) && filesize($datei) > 1024 * 1024) {
        rename($datei, $datei . '.1');
    }

    file_put_contents($datei, date('c') . ' ' . $zeile . "\n", FILE_APPEND | LOCK_EX);
}

set_exception_handler(function (Throwable $fehler) {
    protokoll(get_class($fehler) . ': ' . $fehler->getMessage() . ' in ' . $fehler->getFile() . ':' . $fehler->getLine());
    http_response_code(500);
    echo 'Da ist etwas schiefgegangen. Bitte versuch es später nochmals.';
});

Das Protokoll lädst du im Reiter «Daten» herunter. Schreib keine Passwörter, Schlüssel oder vollständigen Zahlungsdaten hinein.

9. Ordnung im Datenbereich

  • Eine klare Ablage: config.json zuoberst, darunter Ordner wie uploads/ und export/, die Datenbank als daten.sqlite.
  • Platz im Auge behalten. Live und Vorschau teilen sich die Grenze deines Plans; der Reiter «Daten» zeigt, wie viel belegt ist. Ist er voll, nimmt das Portal nichts mehr an.
  • Aufräumen, ohne Cron. Es gibt nichts, was regelmässig von selbst läuft. Lass Aufräumarbeiten darum gelegentlich bei einem gewöhnlichen Aufruf mitlaufen:
// Etwa bei jedem hundertsten Aufruf: Uploads löschen, die älter als 90 Tage sind.
if (random_int(1, 100) === 1) {
    foreach (glob(datenpfad('uploads/*')) ?: [] as $alt) {
        if (filemtime($alt) < time() - 90 * 86400) {
            unlink($alt);
        }
    }
}
  • sessions nicht anfassen. Dort verwaltet PHP die Anmeldungen deiner Besucher.

10. Selbst sichern

Der Datenbereich wird nicht gesichert. Deine Website-Dateien lassen sich über die Versionen zurückholen, was dein PHP geschrieben hat, nicht. Lade wichtige Daten deshalb regelmässig im Reiter «Daten» herunter – oder bau deiner Website eine Exportfunktion hinter einem Login, die Bestellungen als CSV ausgibt. Was du selbst einmal im Monat herunterlädst, ist das, was du nach einem Fehlgriff noch hast.

Pausen und was sie bedeuten

Nach 15 Minuten ohne Besuch macht deine Website eine Pause; der nächste Aufruf startet sie wieder und dauert etwas länger. Sitzungen und der Datenbereich überstehen das. Was in /tmp lag, nicht – leg dort nur ab, was die laufende Anfrage braucht.

Deine eigenen PHP-Dateien hält der Cache bis zum nächsten Veröffentlichen fest. Das ist schnell und richtig, denn sie ändern sich nur dann. Für PHP-Dateien im Datenbereich gilt dasselbe – darum gehört die Konfiguration in eine JSON-Datei (Regel 1).

Bibliotheken mit Composer

Auf dem Server gibt es keine Kommandozeile und kein Composer. Bibliotheken wie PHPMailer installierst du auf deinem Rechner und lädst den Ordner vendor/ mit deinen Website-Dateien hoch – am einfachsten als ZIP im Reiter «Dateien».

composer config platform.php 8.4
composer require phpmailer/phpmailer
composer install --no-dev --optimize-autoloader

composer config platform.php 8.4 sorgt dafür, dass Composer Versionen wählt, die mit PHP 8.4 laufen – auch wenn auf deinem Rechner eine andere Version installiert ist. composer.json und composer.lock darfst du mit hochladen; die Website liefert sie nie aus.

Häufige Fehler und ihre Ursache

Was du siehst Ursache Abhilfe
open_basedir restriction in effect Ein Pfad ausserhalb der erlaubten Orte Pfade mit datenpfad() bauen (Regel 2)
Read-only file system oder Permission denied beim Schreiben Schreiben in die eigenen Website-Dateien In den Datenbereich schreiben
Läuft live, in der Vorschau nicht Die Konfiguration fehlt im Vorschau-Bereich Im Reiter «Daten» auf «Vorschau» schalten und dort ablegen
mail() gibt false zurück Über mail() wird nichts verschickt SMTP bei einem Mailanbieter (Regel 6)
could not find driver oder keine Verbindung zu MySQL Kein Datenbankserver, Port 3306 gesperrt SQLite (Regel 4)
Verbindung zu einem Dienst hängt Ein anderer Port als 80, 443, 587 oder 465 Den HTTPS-Zugang des Dienstes nehmen
Upload bricht mit Fehler 413 ab Datei grösser als 100 MB Grenze im Formular nennen (Regel 7)
Live nur eine weisse Seite Ein Fehler, der live nicht angezeigt wird Dieselbe Seite in der Vorschau aufrufen
Neue Konfiguration wirkt nicht Eine config.php im Datenbereich, vom Cache gehalten JSON statt PHP (Regel 1)
Besucher werden ständig abgemeldet session.save_path wurde umgestellt Die Einstellung lassen – Sitzungen liegen schon im Datenbereich
Das Portal nimmt keine Datei mehr an Der Platz des Plans ist aufgebraucht Aufräumen (Regel 9) oder mehr Platz anfragen

Hat das nicht geholfen? hallo@madpublishing.ch