EN
Get started

Help · Website

Best practices for PHP websites

Ten rules that keep a PHP website with us secure, robust and easy to maintain – each with an example. The three most important: secrets belong in the data area, never in your website files or in the chat. You read the path to it from PUBLISHING_STORAGE. And you look for errors in the preview, because only it shows them.

The basics – which directories exist, what the «Data» tab can do, which limits apply – are in PHP and the data area. Your assistant knows these rules too and builds accordingly.

1. Secrets belong in the data area

API keys, SMTP passwords and credentials live in a file in the data area that you upload in the «Data» tab. Not in your website files, and not in the chat.

  • Not in the website files: everything there gets published, and every change is kept as a version. A key that was there once is still in the history after you delete it.
  • Not in the chat: whatever you write there passes through a language model. Your assistant tells you which file with which fields its code expects – you fill in the values yourself.

Use JSON, not PHP, for the configuration. A config.php in the data area is held by the PHP cache: if you replace it, the new one only takes effect after the website's next pause. A JSON file is read fresh on every request.

{
  "smtp": { "host": "mail.provider.com", "user": "website@your-domain.com", "password": "…" },
  "payment": { "key": "sk_live_…" }
}
<?php
function config(): array
{
    static $config = null;

    if ($config === null) {
        $file = data_path('config.json'); // data_path() is in rule 2

        if (!is_file($file)) {
            throw new RuntimeException('config.json is missing from the data area.');
        }

        $config = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
    }

    return $config;
}

2. Read the path from the environment

Do not write /var/www/storage in twenty places. A small function is enough, and on your own computer you set PUBLISHING_STORAGE to a folder there – the same code then runs in both places.

<?php
function data_path(string $file = ''): string
{
    $base = rtrim(getenv('PUBLISHING_STORAGE') ?: '/var/www/storage', '/');

    return $file === '' ? $base : $base . '/' . ltrim($file, '/');
}

getenv() and not $_ENV – that stays empty here.

3. Separate preview and live through the data, not the code

The preview has its own data area. Put a config.json with test keys there – the test mode of your payment API, for example – and one with the real ones live. The same code then uses the test in the preview and the real thing live, without a single condition.

If you do need to know in the code:

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

4. Data in SQLite instead of a database server

There is no database server with us, and you cannot reach someone else's (MySQL needs port 3306; only 80, 443, 587 and 465 are open). For enquiries, orders and registrations, SQLite is enough: a single file in the data area, with everything you expect from a database.

<?php
$db = new PDO('sqlite:' . data_path('data.sqlite'));
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Wait instead of failing when someone else is writing.
$db->exec('PRAGMA busy_timeout = 5000');

$db->exec('CREATE TABLE IF NOT EXISTS enquiries (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL,
    message TEXT NOT NULL,
    received TEXT NOT NULL
)');

$insert = $db->prepare('INSERT INTO enquiries (name, email, message, received) VALUES (?, ?, ?, ?)');
$insert->execute([$name, $email, $message, date('c')]);
  • Always with placeholders (?), never with assembled text. That is the protection against SQL injection.
  • For a backup, the one file is enough – download it in the «Data» tab, ideally at a quiet time.

For very little data – a list of twenty entries – a JSON file will do as well. Then rule 5 applies.

5. Writing at the same time: lock and replace

Up to eight requests run at the same time. Two that write the same file destroy it – and a half-written JSON file takes the whole website down.

Appending with a lock:

file_put_contents(data_path('signups.csv'), $line . "\n", FILE_APPEND | LOCK_EX);

Replacing entirely via a temporary file in the same folder. The rename happens in one go: whoever reads sees either the old or the new file, never half of one.

function save(string $file, string $content): void
{
    $target = data_path($file);
    $half = $target . '.' . bin2hex(random_bytes(4)) . '.tmp';

    file_put_contents($half, $content, LOCK_EX);
    rename($half, $target);
}

Read, change, write back – a counter, say – needs a lock around all of it:

$lock = fopen(data_path('counter.lock'), 'c');
flock($lock, LOCK_EX);

$count = (int) @file_get_contents(data_path('counter.txt'));
save('counter.txt', (string) ($count + 1));

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

If it gets more than that, use SQLite – all of this is already solved there.

6. Mail via SMTP, not mail()

mail() sends nothing with us and returns false. For a contact form you use SMTP with a mail provider – the better way anyway: the provider takes care of sender verification (SPF, DKIM), and your mail does not end up in spam.

  • Port 587 with STARTTLS or 465 with TLS. Port 25 is blocked.
  • Credentials in the config.json in the data area (rule 1).
  • The sender is your own address, the visitor's address goes into «Reply-To». Entering the visitor's address as the sender forges a sender – and that is exactly what mail programs sort out.
  • Store the enquiry as well (rule 4). If the mail provider fails once, nothing is lost.

With 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('website@your-domain.com', 'Website');
$mail->addReplyTo($email, $name);
$mail->addAddress('contact@your-domain.com');
$mail->Subject = 'New enquiry via the website';
$mail->Body = $message;

$mail->send();

In the preview this sends real mail if the same credentials are there. So put a configuration with a test address as recipient into the preview area (rule 3).

7. Accepting visitors' uploads safely

An uploaded file is foreign input – and so are its name, its size and its claimed type.

<?php
$file = $_FILES['receipt'] ?? null;

if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
    exit('The upload did not work.');
}

if ($file['size'] > 10 * 1024 * 1024) {
    exit('The file is larger than 10 MB.');
}

// Determine the type from the content, not from the name.
$type = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf'];

if (!isset($allowed[$type])) {
    exit('JPG, PNG and PDF are allowed.');
}

// A random name of your own – never the visitor's.
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$type];

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

move_uploaded_file($file['tmp_name'], data_path('uploads/' . $name));

Serving it then goes through a script of your own that first checks who may see the file – and checks the name strictly before using it:

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="receipt.' . pathinfo($name, PATHINFO_EXTENSION) . '"');
header('X-Content-Type-Options: nosniff');
readfile(data_path('uploads/' . $name));

State the limit in the form itself – otherwise a visitor with a file that is too large only sees an error page. The outermost limit with us is 100 MB per file.

8. Finding errors

In the preview, PHP shows every error, including notices about deprecated functions. Live, it shows none – visitors should not see paths and queries. You cannot view a log of the published website. If you need one, write it yourself into the data area, with a limit so it does not grow forever:

function log_line(string $line): void
{
    $file = data_path('website.log');

    // From 1 MB a new file begins; the previous one stays as .1.
    if (is_file($file) && filesize($file) > 1024 * 1024) {
        rename($file, $file . '.1');
    }

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

set_exception_handler(function (Throwable $error) {
    log_line(get_class($error) . ': ' . $error->getMessage() . ' in ' . $error->getFile() . ':' . $error->getLine());
    http_response_code(500);
    echo 'Something went wrong. Please try again later.';
});

You download the log in the «Data» tab. Do not write passwords, keys or complete payment details into it.

9. Order in the data area

  • A clear layout: config.json at the top, below it folders such as uploads/ and export/, the database as data.sqlite.
  • Keep an eye on the space. Live and preview share your plan's limit; the «Data» tab shows how much is used. When it is full, the portal accepts nothing more.
  • Cleaning up, without cron. Nothing runs on its own on a schedule. So let clean-up work run occasionally along with an ordinary request:
// Roughly every hundredth request: delete uploads older than 90 days.
if (random_int(1, 100) === 1) {
    foreach (glob(data_path('uploads/*')) ?: [] as $old) {
        if (filemtime($old) < time() - 90 * 86400) {
            unlink($old);
        }
    }
}
  • Leave sessions alone. PHP manages your visitors' logins there.

10. Back up yourself

The data area is not backed up. Your website files can be brought back through the versions; what your PHP has written cannot. So download important data regularly in the «Data» tab – or give your website an export function behind a login that outputs orders as CSV. What you download yourself once a month is what you still have after a mistake.

Pauses and what they mean

After 15 minutes without a visit your website pauses; the next request starts it again and takes a little longer. Sessions and the data area survive that. Whatever was in /tmp does not – only put there what the current request needs.

The cache holds your own PHP files until the next publish. That is fast and correct, because they only change then. The same applies to PHP files in the data area – which is why the configuration belongs in a JSON file (rule 1).

Libraries with Composer

There is no command line and no Composer on the server. You install libraries such as PHPMailer on your computer and upload the vendor/ folder with your website files – most easily as a ZIP in the «Files» tab.

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

composer config platform.php 8.4 makes Composer pick versions that run on PHP 8.4 – even if a different version is installed on your computer. You may upload composer.json and composer.lock too; the website never serves them.

Common errors and their cause

What you see Cause Remedy
open_basedir restriction in effect A path outside the allowed locations Build paths with data_path() (rule 2)
Read-only file system or Permission denied when writing Writing into your own website files Write into the data area
Works live, not in the preview The configuration is missing from the preview area Switch to «Preview» in the «Data» tab and put it there
mail() returns false Nothing is sent via mail() SMTP with a mail provider (rule 6)
could not find driver or no connection to MySQL No database server, port 3306 blocked SQLite (rule 4)
A connection to a service hangs A port other than 80, 443, 587 or 465 Use the service's HTTPS access
Upload aborts with error 413 File larger than 100 MB State the limit in the form (rule 7)
Live only shows a blank page An error that is not shown live Open the same page in the preview
A new configuration has no effect A config.php in the data area, held by the cache JSON instead of PHP (rule 1)
Visitors keep getting logged out session.save_path was changed Leave the setting – sessions already live in the data area
The portal no longer accepts files The plan's space is used up Clean up (rule 9) or ask for more space

Didn’t that help? hallo@madpublishing.ch