PHP PostgreSQL guestbook

A PHP guestbook collects names and notes, shows every message with its time, and keeps them through new deployments. Tokay runs the page as one Web Service with a managed PostgreSQL database.

Last updated

Start here when you want saved data without bringing in a full PHP framework. The page, form handling, and database queries all live in index.php, so you can follow the complete request in one place.

Runtime
PHP
Framework
None
Service types
Web Service
Resources
PostgreSQL
Required secrets
None

What it does

The guestbook opens with a welcome note and a form for your name and message.

Press Sign the book and your entry appears at the top with a UTC timestamp. Refresh the page whenever you like and the database keeps it in place.

If either field is empty, the page explains what is missing instead of writing an incomplete entry.

Deploy it

Bring this folder to Tokay any way you like. Paste or upload it in the dashboard, push it with git, or hand it to your AI agent along with our agent instructions. Tokay figures out the rest.

Working in Claude, ChatGPT, VS Code, or another MCP client? Connect the Tokay MCP server and ask your agent to deploy this example. It signs in through your browser, so there is no token to paste.

New to Tokay? The getting started guide walks you through your first deploy.

Finish setup in Tokay

The guestbook needs PostgreSQL to keep its messages. Tokay has already prepared the database setup based on the app. You only need to confirm where DATABASE_URL should point.

  1. Choose Environment from the guestbook Service.
  2. Find DATABASE_URL under From your infrastructure.
  3. Choose Confirm database.
  4. Keep the suggested app name.
  5. Choose Confirm.

After DATABASE_URL reaches Good to go, finish the deployment tracker. Tokay creates the database and gives the private connection to the app. Use Go Live if the tracker shows it, then open the guestbook from the Service's live URL.

Try it

  1. Sign the book and refresh the page to confirm your note remains.
  2. Change a piece of page copy, then deploy again.
  3. Open the updated page. Your note is still there because PostgreSQL lives beyond the app release.

How it works

Wondering who creates the table? The app does that on its first database connection, then uses prepared statements for every submitted message. Tokay supplies the private PostgreSQL connection through DATABASE_URL, so its password never appears in your code.

Secrets

You do not need to create a secret. Tokay supplies DATABASE_URL when you confirm the managed PostgreSQL database.

Grow from here

Try the Laravel event space booking app when you want a full framework, migrations, and scheduled reminders.

.htaccess Raw

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L,QSA]

index.php Raw

<?php
declare(strict_types=1);

function escape(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function database(): PDO
{
    $url = getenv('DATABASE_URL');
    if (!$url) {
        throw new RuntimeException('DATABASE_URL is required.');
    }

    $parts = parse_url($url);
    if ($parts === false || !isset($parts['host'], $parts['path'])) {
        throw new RuntimeException('DATABASE_URL is not a valid PostgreSQL URL.');
    }

    $host = $parts['host'];
    $port = $parts['port'] ?? 5432;
    $name = ltrim($parts['path'], '/');
    $user = rawurldecode($parts['user'] ?? '');
    $password = rawurldecode($parts['pass'] ?? '');

    return new PDO(
        "pgsql:host={$host};port={$port};dbname={$name}",
        $user,
        $password,
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]
    );
}

$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
if ($path === '/health') {
    header('Content-Type: application/json');
    echo json_encode(['ok' => true], JSON_THROW_ON_ERROR);
    exit;
}

try {
    $db = database();
    $db->exec(<<<'SQL'
        CREATE TABLE IF NOT EXISTS guestbook_entries (
            id BIGSERIAL PRIMARY KEY,
            name TEXT NOT NULL,
            message TEXT NOT NULL,
            created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
        )
        SQL);

    $count = (int) $db->query('SELECT COUNT(*) FROM guestbook_entries')->fetchColumn();
    if ($count === 0) {
        $seed = $db->prepare('INSERT INTO guestbook_entries (name, message) VALUES (:name, :message)');
        $seed->execute(['name' => 'Your host', 'message' => 'Welcome. Sign below.']);
    }

    if ($_SERVER['REQUEST_METHOD'] === 'POST' && $path === '/sign') {
        $name = trim((string) ($_POST['name'] ?? ''));
        $message = trim((string) ($_POST['message'] ?? ''));
        if ($name === '' || $message === '') {
            http_response_code(400);
            throw new InvalidArgumentException('Add your name and a note.');
        }

        $insert = $db->prepare('INSERT INTO guestbook_entries (name, message) VALUES (:name, :message)');
        $insert->execute([
            'name' => mb_substr($name, 0, 60),
            'message' => mb_substr($message, 0, 500),
        ]);
        header('Location: /', true, 303);
        exit;
    }

    $entries = $db->query(<<<'SQL'
        SELECT name, message, created_at AT TIME ZONE 'UTC' AS created_at
        FROM guestbook_entries
        ORDER BY id DESC
        SQL)->fetchAll();
} catch (Throwable $error) {
    error_log($error->__toString());
    $entries = [];
    $pageError = $error instanceof InvalidArgumentException
        ? $error->getMessage()
        : 'The guestbook cannot reach its database yet.';
}
?>
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>The PHP guestbook</title>
  <style>
    :root { font-family: ui-sans-serif, system-ui, sans-serif; color: #28251f; background: #f6f1e8; }
    * { box-sizing: border-box; }
    body { margin: 0; }
    main { width: min(680px, calc(100% - 2rem)); margin: 0 auto; padding: 4rem 0; }
    h1 { margin: 0; font-family: Georgia, serif; font-size: clamp(2.5rem, 8vw, 4.5rem); font-weight: 500; }
    .lede, .meta { color: #70695d; }
    form, article { padding: 1.2rem; border: 1px solid #d7cebf; border-radius: 10px; background: #fffdf8; }
    form { display: grid; gap: 0.9rem; margin: 2rem 0; }
    label { display: grid; gap: 0.35rem; font-weight: 700; font-size: 0.85rem; }
    input, textarea, button { font: inherit; }
    input, textarea { padding: 0.7rem; border: 1px solid #bcb1a1; border-radius: 6px; }
    textarea { min-height: 6rem; resize: vertical; }
    button { justify-self: start; padding: 0.7rem 1rem; border: 0; border-radius: 6px; color: white; background: #28251f; }
    .error { color: #a01933; }
    section { display: grid; gap: 0.8rem; }
    article p { margin-top: 0; white-space: pre-wrap; }
    article footer { color: #70695d; font-size: 0.8rem; }
  </style>
</head>
<body>
  <main>
    <h1>The guestbook</h1>
    <p class="lede">A small PHP app with a real PostgreSQL home.</p>
    <?php if (isset($pageError)): ?><p class="error"><?= escape($pageError) ?></p><?php endif; ?>
    <form action="/sign" method="post">
      <label>Your name<input name="name" maxlength="60" required></label>
      <label>Your note<textarea name="message" maxlength="500" required></textarea></label>
      <button type="submit">Sign the book</button>
    </form>
    <p class="meta"><?= count($entries) ?> <?= count($entries) === 1 ? 'entry' : 'entries' ?></p>
    <section>
      <?php foreach ($entries as $entry): ?>
        <article>
          <p><?= nl2br(escape((string) $entry['message'])) ?></p>
          <footer><?= escape((string) $entry['name']) ?> ยท <?= escape((string) $entry['created_at']) ?> UTC</footer>
        </article>
      <?php endforeach; ?>
    </section>
  </main>
</body>
</html>

Built from revision 52c7b7f

Guide: Deploy an app with a Postgres database