Aide · Site web
Bonnes pratiques pour les sites PHP
Dix règles qui gardent un site PHP chez nous sûr, robuste et facile à entretenir – chacune avec un exemple. Les trois plus importantes : les secrets vont dans l’espace de données, jamais dans vos fichiers de site ni dans la conversation. Le chemin vers cet espace se lit dans PUBLISHING_STORAGE. Et les erreurs se cherchent dans l’aperçu, car lui seul les affiche.
Les bases – quels répertoires existent, ce que fait l’onglet « Données », quelles limites s’appliquent – se trouvent dans PHP et l’espace de données. Votre assistant connaît aussi ces règles et construit en conséquence.
1. Les secrets vont dans l’espace de données
Les clés d’API, mots de passe SMTP et identifiants se trouvent dans un fichier de l’espace de données, que vous téléversez dans l’onglet « Données ». Pas dans vos fichiers de site, et pas dans la conversation.
- Pas dans les fichiers de site : tout ce qui s’y trouve est publié, et chaque modification est conservée comme version. Une clé qui y a figuré une fois reste dans l’historique même après sa suppression.
- Pas dans la conversation : ce que vous y écrivez passe par un modèle de langage. Votre assistant vous indique quel fichier, avec quels champs, son code attend – les valeurs, vous les saisissez vous-même.
Pour la configuration, utilisez JSON et non PHP. Un config.php dans l’espace de données est retenu par le cache de PHP : si vous le remplacez, le nouveau ne prend effet qu’après la prochaine pause du site. Un fichier JSON est relu à chaque appel.
{
"smtp": { "host": "mail.fournisseur.ch", "user": "site@votre-domaine.ch", "password": "…" },
"paiement": { "cle": "sk_live_…" }
}
<?php
function config(): array
{
static $config = null;
if ($config === null) {
$fichier = chemin_donnees('config.json'); // chemin_donnees() se trouve dans la règle 2
if (!is_file($fichier)) {
throw new RuntimeException('config.json manque dans l’espace de données.');
}
$config = json_decode(file_get_contents($fichier), true, 512, JSON_THROW_ON_ERROR);
}
return $config;
}
2. Lire le chemin dans l’environnement
N’écrivez pas /var/www/storage à vingt endroits. Une petite fonction suffit, et sur votre propre ordinateur vous réglez PUBLISHING_STORAGE sur un dossier local – le même code tourne alors aux deux endroits.
<?php
function chemin_donnees(string $fichier = ''): string
{
$base = rtrim(getenv('PUBLISHING_STORAGE') ?: '/var/www/storage', '/');
return $fichier === '' ? $base : $base . '/' . ltrim($fichier, '/');
}
getenv() et non $_ENV – celui-ci reste vide ici.
3. Séparer aperçu et en ligne par les données, pas par le code
L’aperçu a son propre espace de données. Déposez-y un config.json avec des clés de test – le mode test de votre API de paiement, par exemple – et en ligne un fichier avec les vraies. Le même code utilise alors le test dans l’aperçu et le vrai en ligne, sans la moindre condition.
Si vous devez malgré tout le savoir dans le code :
$apercu = getenv('PUBLISHING_VORSCHAU') === '1';
4. Les données dans SQLite plutôt que dans un serveur de base de données
Il n’y a pas de serveur de base de données chez nous, et vous ne pouvez pas en joindre un autre (MySQL a besoin du port 3306 ; seuls 80, 443, 587 et 465 sont ouverts). Pour les demandes, commandes et inscriptions, SQLite suffit : un seul fichier dans l’espace de données, avec tout ce qu’on attend d’une base de données.
<?php
$db = new PDO('sqlite:' . chemin_donnees('donnees.sqlite'));
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Attendre plutôt qu’échouer quand quelqu’un d’autre écrit.
$db->exec('PRAGMA busy_timeout = 5000');
$db->exec('CREATE TABLE IF NOT EXISTS demandes (
id INTEGER PRIMARY KEY,
nom TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL,
recu TEXT NOT NULL
)');
$ajout = $db->prepare('INSERT INTO demandes (nom, email, message, recu) VALUES (?, ?, ?, ?)');
$ajout->execute([$nom, $email, $message, date('c')]);
- Toujours avec des paramètres (
?), jamais avec du texte assemblé. C’est la protection contre l’injection SQL. - Pour une sauvegarde, ce seul fichier suffit – téléchargez-le dans l’onglet « Données », idéalement à un moment calme.
Pour très peu de données – une liste de vingt entrées – un fichier JSON suffit aussi. La règle 5 s’applique alors.
5. Écrire en même temps : verrouiller et remplacer
Jusqu’à huit requêtes tournent en même temps. Deux qui écrivent le même fichier le détruisent – et un fichier JSON à moitié écrit met tout le site hors service.
Ajouter avec un verrou :
file_put_contents(chemin_donnees('inscriptions.csv'), $ligne . "\n", FILE_APPEND | LOCK_EX);
Remplacer entièrement via un fichier intermédiaire dans le même dossier. Le renommage se fait d’un coup : qui lit voit soit l’ancien, soit le nouveau fichier, jamais une moitié.
function enregistre(string $fichier, string $contenu): void
{
$cible = chemin_donnees($fichier);
$moitie = $cible . '.' . bin2hex(random_bytes(4)) . '.tmp';
file_put_contents($moitie, $contenu, LOCK_EX);
rename($moitie, $cible);
}
Lire, modifier, réécrire – un compteur, par exemple – demande un verrou autour du tout :
$verrou = fopen(chemin_donnees('compteur.lock'), 'c');
flock($verrou, LOCK_EX);
$etat = (int) @file_get_contents(chemin_donnees('compteur.txt'));
enregistre('compteur.txt', (string) ($etat + 1));
flock($verrou, LOCK_UN);
fclose($verrou);
Au-delà, prenez SQLite – tout cela y est déjà résolu.
6. L’e-mail via SMTP, pas via mail()
mail() n’envoie rien chez nous et renvoie false. Pour un formulaire de contact, utilisez SMTP chez un fournisseur de messagerie – c’est de toute façon la meilleure voie : le fournisseur s’occupe de la vérification de l’expéditeur (SPF, DKIM), et vos messages n’atterrissent pas dans les indésirables.
- Port 587 avec STARTTLS ou 465 avec TLS. Le port 25 est bloqué.
- Les identifiants dans le
config.jsonde l’espace de données (règle 1). - L’expéditeur est votre propre adresse, celle du visiteur va dans « Répondre à ». Mettre l’adresse du visiteur comme expéditeur, c’est falsifier un expéditeur – et c’est exactement ce que les logiciels de messagerie écartent.
- Enregistrez aussi la demande (règle 4). Si le fournisseur tombe en panne une fois, rien n’est perdu.
Avec PHPMailer :
<?php
use PHPMailer\PHPMailer\PHPMailer;
require __DIR__ . '/vendor/autoload.php';
$smtp = config()['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['password'];
$mail->CharSet = 'UTF-8';
$mail->setFrom('site@votre-domaine.ch', 'Site web');
$mail->addReplyTo($email, $nom);
$mail->addAddress('contact@votre-domaine.ch');
$mail->Subject = 'Nouvelle demande via le site';
$mail->Body = $message;
$mail->send();
Dans l’aperçu, cela envoie de vrais messages si les mêmes identifiants s’y trouvent. Déposez donc dans l’espace de l’aperçu une configuration avec une adresse de test comme destinataire (règle 3).
7. Accepter les téléversements des visiteurs en toute sécurité
Un fichier téléversé est une saisie étrangère – son nom, sa taille et son type annoncé aussi.
<?php
$fichier = $_FILES['justificatif'] ?? null;
if (!$fichier || $fichier['error'] !== UPLOAD_ERR_OK) {
exit('Le téléversement n’a pas fonctionné.');
}
if ($fichier['size'] > 10 * 1024 * 1024) {
exit('Le fichier dépasse 10 Mo.');
}
// Déterminer le type d’après le contenu, pas d’après le nom.
$type = (new finfo(FILEINFO_MIME_TYPE))->file($fichier['tmp_name']);
$autorises = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf'];
if (!isset($autorises[$type])) {
exit('Sont autorisés : JPG, PNG et PDF.');
}
// Un nom propre et aléatoire – jamais celui du visiteur.
$nom = bin2hex(random_bytes(16)) . '.' . $autorises[$type];
if (!is_dir(chemin_donnees('uploads'))) {
mkdir(chemin_donnees('uploads'), 0770, true);
}
move_uploaded_file($fichier['tmp_name'], chemin_donnees('uploads/' . $nom));
La livraison passe ensuite par un script à vous, qui vérifie d’abord qui a le droit de voir le fichier – et contrôle strictement le nom avant de l’utiliser :
if (!preg_match('/^[a-f0-9]{32}\.(jpg|png|pdf)$/', $nom)) {
http_response_code(404);
exit;
}
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="justificatif.' . pathinfo($nom, PATHINFO_EXTENSION) . '"');
header('X-Content-Type-Options: nosniff');
readfile(chemin_donnees('uploads/' . $nom));
Indiquez la limite dans le formulaire lui-même – sinon, un visiteur avec un fichier trop lourd ne voit qu’une page d’erreur. La limite extrême chez nous est de 100 Mo par fichier.
8. Trouver les erreurs
Dans l’aperçu, PHP affiche chaque erreur, y compris les avis sur les fonctions obsolètes. En ligne, il n’en affiche aucune – les visiteurs ne doivent pas voir de chemins ni de requêtes. Le journal du site publié ne vous est pas accessible. Si vous en avez besoin, écrivez-le vous-même dans l’espace de données, avec une limite pour qu’il ne grossisse pas sans fin :
function journal(string $ligne): void
{
$fichier = chemin_donnees('site.log');
// À partir de 1 Mo, un nouveau fichier commence ; le précédent reste en .1.
if (is_file($fichier) && filesize($fichier) > 1024 * 1024) {
rename($fichier, $fichier . '.1');
}
file_put_contents($fichier, date('c') . ' ' . $ligne . "\n", FILE_APPEND | LOCK_EX);
}
set_exception_handler(function (Throwable $erreur) {
journal(get_class($erreur) . ' : ' . $erreur->getMessage() . ' dans ' . $erreur->getFile() . ':' . $erreur->getLine());
http_response_code(500);
echo 'Quelque chose s’est mal passé. Merci de réessayer plus tard.';
});
Vous téléchargez le journal dans l’onglet « Données ». N’y écrivez ni mots de passe, ni clés, ni données de paiement complètes.
9. De l’ordre dans l’espace de données
- Un rangement clair :
config.jsontout en haut, en dessous des dossiers commeuploads/etexport/, la base de données sousdonnees.sqlite. - Surveiller l’espace. En ligne et aperçu partagent la limite de votre plan ; l’onglet « Données » indique combien est occupé. S’il est plein, le portail n’accepte plus rien.
- Faire le ménage, sans cron. Rien ne s’exécute tout seul à intervalles réguliers. Faites donc tourner le ménage de temps en temps lors d’un appel ordinaire :
// Environ à chaque centième appel : supprimer les téléversements de plus de 90 jours.
if (random_int(1, 100) === 1) {
foreach (glob(chemin_donnees('uploads/*')) ?: [] as $ancien) {
if (filemtime($ancien) < time() - 90 * 86400) {
unlink($ancien);
}
}
}
- Ne pas toucher à
sessions. C’est là que PHP gère les connexions de vos visiteurs.
10. Sauvegarder soi-même
L’espace de données n’est pas sauvegardé. Vos fichiers de site se récupèrent via les versions ; ce que votre PHP a écrit, non. Téléchargez donc régulièrement les données importantes dans l’onglet « Données » – ou dotez votre site d’une fonction d’export, derrière une connexion, qui sort les commandes en CSV. Ce que vous téléchargez vous-même une fois par mois, c’est ce qui vous reste après une fausse manœuvre.
Les pauses et ce qu’elles signifient
Après 15 minutes sans visite, votre site fait une pause ; l’appel suivant le redémarre et prend un peu plus de temps. Les sessions et l’espace de données le supportent. Ce qui se trouvait dans /tmp, non – n’y déposez que ce dont la requête en cours a besoin.
Le cache garde vos propres fichiers PHP jusqu’à la prochaine publication. C’est rapide et juste, car ils ne changent qu’à ce moment-là. Il en va de même pour les fichiers PHP de l’espace de données – c’est pourquoi la configuration va dans un fichier JSON (règle 1).
Bibliothèques avec Composer
Il n’y a ni ligne de commande ni Composer sur le serveur. Vous installez les bibliothèques comme PHPMailer sur votre ordinateur et téléversez le dossier vendor/ avec vos fichiers de site – le plus simple en ZIP dans l’onglet « Fichiers ».
composer config platform.php 8.4
composer require phpmailer/phpmailer
composer install --no-dev --optimize-autoloader
composer config platform.php 8.4 fait choisir à Composer des versions compatibles avec PHP 8.4 – même si une autre version est installée sur votre ordinateur. Vous pouvez téléverser aussi composer.json et composer.lock ; le site ne les livre jamais.
Erreurs fréquentes et leur cause
| Ce que vous voyez | Cause | Remède |
|---|---|---|
open_basedir restriction in effect |
Un chemin hors des emplacements autorisés | Construire les chemins avec chemin_donnees() (règle 2) |
Read-only file system ou Permission denied à l’écriture |
Écriture dans vos propres fichiers de site | Écrire dans l’espace de données |
| Fonctionne en ligne, pas dans l’aperçu | La configuration manque dans l’espace de l’aperçu | Passer à « Aperçu » dans l’onglet « Données » et l’y déposer |
mail() renvoie false |
Rien n’est envoyé via mail() |
SMTP chez un fournisseur de messagerie (règle 6) |
could not find driver ou pas de connexion à MySQL |
Pas de serveur de base de données, port 3306 bloqué | SQLite (règle 4) |
| Une connexion à un service reste bloquée | Un autre port que 80, 443, 587 ou 465 | Utiliser l’accès HTTPS du service |
| Le téléversement s’arrête avec l’erreur 413 | Fichier de plus de 100 Mo | Indiquer la limite dans le formulaire (règle 7) |
| En ligne, seulement une page blanche | Une erreur qui n’est pas affichée en ligne | Ouvrir la même page dans l’aperçu |
| Une nouvelle configuration reste sans effet | Un config.php dans l’espace de données, retenu par le cache |
JSON plutôt que PHP (règle 1) |
| Les visiteurs sont sans cesse déconnectés | session.save_path a été modifié |
Laisser ce réglage – les sessions sont déjà dans l’espace de données |
| Le portail n’accepte plus de fichiers | L’espace du plan est épuisé | Faire le ménage (règle 9) ou demander plus d’espace |
Cela ne vous a pas aidé ? hallo@madpublishing.ch