Build a Mini Chat App with PHP

Published Updated

A mini chat app is still a useful PHP exercise, provided you treat it as a small database-backed application instead of a toy script that appends lines to a text file.

The old version of this tutorial belonged to the shoutbox and guestbook era, when a surprising amount of public web software was a form, a flat file, and optimism. The current version should teach boundaries: sessions, message writes, message reads, output escaping, and a modest refresh loop.

Picture each chat room as a meeting room with a checked guest list. The server decides who may enter and speak, the database records each message in order, and the browser displays only the messages returned for that room. The code looks harmless until two people submit at once, someone pastes markup into the message box, or a tab stays open all afternoon.

Guide Path

Read this after building a small PHP database app and before using sockets in PHP. The chat app is where polling, sessions, CSRF, escaped output, and message queries meet in one small surface.

What the App Should Prove

The app only needs a few moving parts, but each one should have a clear owner:

  • A session-backed user identity.
  • A room or channel identifier.
  • A messages table with an index that supports recent reads.
  • A POST endpoint that validates and stores a message.
  • A GET endpoint that returns newer messages.
  • Escaped output in HTML and text-only insertion in JavaScript.
  • CSRF protection for every state-changing request.

That list is the reason this tutorial sits between building a small PHP database app and PHP security fundamentals. The chat surface is just interactive enough to expose the habits that matter in ordinary PHP work, without pretending the first stop has to be a real-time platform.

Start with the Schema

Start with tables plain enough that the access pattern is visible. A room lets you avoid hard-coding one global conversation, and an indexed message stream keeps polling from turning into a full-table scan.

CREATE TABLE chat_rooms (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    slug VARCHAR(100) NOT NULL UNIQUE,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE chat_room_members (
    room_id BIGINT UNSIGNED NOT NULL,
    user_id BIGINT UNSIGNED NOT NULL,
    PRIMARY KEY (room_id, user_id),
    CONSTRAINT chat_members_room_fk
        FOREIGN KEY (room_id) REFERENCES chat_rooms (id)
        ON DELETE CASCADE,
    CONSTRAINT chat_members_user_fk
        FOREIGN KEY (user_id) REFERENCES users (id)
        ON DELETE CASCADE
);

CREATE TABLE chat_messages (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    room_id BIGINT UNSIGNED NOT NULL,
    user_id BIGINT UNSIGNED NOT NULL,
    body TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX chat_room_created_idx (room_id, created_at, id),
    CONSTRAINT chat_messages_room_fk
        FOREIGN KEY (room_id) REFERENCES chat_rooms (id)
        ON DELETE CASCADE,
    CONSTRAINT chat_messages_user_fk
        FOREIGN KEY (user_id) REFERENCES users (id)
        ON DELETE CASCADE
);

-- After creating room 1 and test user 1:
INSERT INTO chat_room_members (room_id, user_id)
VALUES (1, 1);

Use InnoDB so the foreign keys are enforced. The database should know that a message belongs to a room and a user. If that relationship lives only in PHP comments, a maintenance script or import job can create rows the application cannot explain.

That index is not decoration; it matches the read path. The fetch endpoint usually asks for messages in one room after the last message the browser has seen, so room_id, created_at, and id should be cheap to read together.

Insert Messages with PDO

The write path should validate the body before SQL and use prepared statements for values. PDO placeholders are for values, not arbitrary SQL fragments, which is exactly what a chat form needs.

<?php

function createMessage(PDO $pdo, int $roomId, int $userId, string $body): void
{
    $body = trim($body);

    if ($body === "") {
        throw new InvalidArgumentException("Message body is required.");
    }

    if (mb_strlen($body) > 1000) {
        throw new InvalidArgumentException("Keep messages under 1000 characters.");
    }

    $statement = $pdo->prepare(
        "INSERT INTO chat_messages (room_id, user_id, body)
         VALUES (:room_id, :user_id, :body)",
    );

    $statement->execute([
        "room_id" => $roomId,
        "user_id" => $userId,
        "body" => $body,
    ]);
}

That function does not know about forms, templates, or redirects. It writes one message and leaves presentation to the request layer. The request handler can decide whether the message came from a normal form post, a fetch request, or a test.

Protect the POST Endpoint

Posting a chat message changes state, so it needs the same protection as any other form. A small app can use a session-backed CSRF token as long as the token is generated server-side and verified on POST.

<?php

session_start();

function csrfToken(): string
{
    if (! isset($_SESSION["csrf_token"])) {
        $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
    }

    return $_SESSION["csrf_token"];
}

function verifyCsrfToken(string $token): void
{
    $sessionToken = $_SESSION["csrf_token"] ?? null;

    if (
        ! is_string($sessionToken)
        || ! hash_equals($sessionToken, $token)
    ) {
        throw new RuntimeException("Invalid form token.");
    }
}

function requireRoomMember(PDO $pdo, int $userId, int $roomId): void
{
    $statement = $pdo->prepare(
        "SELECT 1
         FROM chat_room_members
         WHERE room_id = :room_id AND user_id = :user_id
         LIMIT 1",
    );
    $statement->execute([
        "room_id" => $roomId,
        "user_id" => $userId,
    ]);

    if (! $statement->fetchColumn()) {
        http_response_code(403);
        exit("Room access denied.");
    }
}

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    verifyCsrfToken($_POST["csrf_token"] ?? "");

    $pdo = db();
    $userId = requireUserId();
    // Placeholder: resolve the room from the route or validated query state.
    $roomId = currentRoomId();
    requireRoomMember($pdo, $userId, $roomId);

    createMessage($pdo, $roomId, $userId, $_POST["body"] ?? "");

    header("Location: /chat", true, 303);
    return;
}

Do not use GET for posting messages into the room. Links, crawlers, browser previews, and prefetching should never be able to write to the database.

The room ID is only a requested destination. requireRoomMember() checks the server-side guest list before the write, so changing a form value or URL cannot grant access to another room.

currentRoomId() is a placeholder for resolving and validating the room from the route or query string before the membership check runs.

Escape Output at Render Time

Store the message as text and escape it when rendering HTML. Validation decides what the app is willing to accept before storage. Escaping decides how the accepted text is safely represented in a specific output context.

<ol class="chat-messages">
    <?php foreach ($messages as $message): ?>
        <li data-message-id="<?= (int) $message["id"] ?>">
            <strong><?= htmlspecialchars($message["display_name"], ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8") ?></strong>
            <span><?= htmlspecialchars($message["body"], ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8") ?></span>
        </li>
    <?php endforeach; ?>
</ol>

If you later add Markdown, emoji replacement, or linkification, do it through a real parser or an allowlisted sanitizer. Do not switch to raw innerHTML because the first version worked in a demo.

Fetch Newer Messages

The read endpoint should return messages after the last ID the browser has already displayed. This keeps each refresh small and gives the database an indexed path.

<?php

function messagesSince(PDO $pdo, int $roomId, int $afterId): array
{
    $statement = $pdo->prepare(
        "SELECT m.id, u.display_name, m.body, m.created_at
         FROM chat_messages AS m
         JOIN users AS u ON u.id = m.user_id
         WHERE m.room_id = :room_id
           AND m.id > :after_id
         ORDER BY m.id ASC
         LIMIT 50",
    );

    $statement->execute([
        "room_id" => $roomId,
        "after_id" => $afterId,
    ]);

    return $statement->fetchAll();
}

$pdo = db();
$userId = requireUserId();
$roomId = currentRoomId();
requireRoomMember($pdo, $userId, $roomId);

header("Content-Type: application/json; charset=utf-8");
echo json_encode([
    "messages" => messagesSince($pdo, $roomId, (int) ($_GET["after_id"] ?? 0)),
]);

On the browser side, use fetch() and put message text into textContent, not innerHTML.

let lastId = Number(document.querySelector("[data-message-id]:last-child")?.dataset.messageId ?? 0);

async function refreshMessages() {
  const url = "/chat/messages?after_id=" + encodeURIComponent(lastId);
  const response = await fetch(url, {
    headers: { Accept: "application/json" },
  });

  if (!response.ok) {
    return;
  }

  const payload = await response.json();

  for (const message of payload.messages) {
    const item = document.createElement("li");
    const author = document.createElement("strong");
    const body = document.createElement("span");

    item.dataset.messageId = String(message.id);
    author.textContent = String(message.display_name);
    body.textContent = String(message.body);
    item.append(author, body);
    document.querySelector(".chat-messages").append(item);
    lastId = message.id;
  }
}

setInterval(refreshMessages, 3000);

Polling every few seconds is a fair first version. It works through ordinary hosting, makes load visible, and keeps the failure mode inside normal request handling. Add a room limit, a per-user rate limit, and a cap on returned messages before you call it production-ready.

Polling, SSE, or WebSockets

The transport choice should follow the product, not the other way around.

Use polling when the chat is low volume, the app is small, and you want ordinary PHP request handling. A three-second interval is not glamorous, but it is easy to debug, easy to throttle, and easy to cache around.

If you want the refresh endpoint without the full chat project, read smart auto refresh with PHP for polling discipline, hidden-tab pauses, and the point where SSE or WebSockets become worth the extra machinery.

Use Server-Sent Events when the server needs to push new messages to the browser and the browser does not need to send messages over the same connection. SSE is one-way from server to client, which fits many notification streams, but it asks more of your hosting model than a normal request. Be careful with long-lived PHP workers and connection limits before using it on busy pages.

Use WebSockets when the conversation needs a persistent two-way connection, typing indicators, presence, or high-frequency events. At that point you are no longer writing a little PHP tutorial; you are operating a real-time service. That may be correct, but it is a different piece of infrastructure, and using sockets in PHP is the lower-level transport lesson beneath that choice.

What Not to Copy from Old Mini Chats

The old examples usually had the same problems:

  • Appending messages to one public text file.
  • Trusting a username from a hidden field.
  • Rendering stored messages without escaping.
  • Posting by query string.
  • Reading the entire history on every refresh.
  • Letting the browser refresh the whole page every few seconds.

Those shortcuts were common because early PHP made them easy and shared hosting made everything else feel like overkill. They are the wrong defaults for a current PHP app. A mini chat can still be plain PHP, but it should use the same floor as any small app: sessions, CSRF, prepared statements, escaped output, and a database query that has a chance to stay fast.

Common Pitfalls & Debugging

A User Can Post into Another Room

Symptom: changing a room ID in a request lets one account read or post in a different room. Cause: the handler trusts the requested ID without checking membership. Fix: load the authenticated user on the server and require room membership before both read and write queries.

Message Markup Runs in the Page

Symptom: a message changes page markup or executes a script. Cause: stored text was inserted through raw HTML or rendered without escaping. Fix: use htmlspecialchars() in PHP templates and textContent when JavaScript adds messages.

Polling Repeats or Skips Messages

Symptom: a refresh repeats old messages or misses rows with the same timestamp. Cause: the client uses time alone as its cursor or starts overlapping refreshes. Fix: query by the last increasing message ID, order by ID, and do not start a new request while the previous refresh is unfinished.

Frequently Asked Questions

Can two open tabs show the same message twice?

Yes, if both poll independently and the client never checks whether it already rendered a given message id. Guard the render step by id rather than trusting each response to contain only new messages.

Should polling slow down while a tab sits idle?

It is a sensible enhancement, usually called backoff: the interval grows when nothing new has arrived and resets on activity. The example here keeps one fixed interval because it is simpler to follow.

Should each message be a row, or can they share one JSON column?

Its own row. A single JSON column blocks querying, pagination, and indexing by time or sender, and turns every new message into a rewrite of an ever-growing value instead of a small insert.

Can chat messages contain Markdown?

Yes, but parse Markdown with a maintained parser and sanitize the generated HTML through a strict allowlist before rendering it. Keep the plain-text version first, because switching from textContent to raw innerHTML removes the browser-side output boundary.

Should the message list auto-scroll to new messages, or wait for the user to scroll down?

Auto-scroll only when the visitor is already at the bottom of the message list. Scrolling that ignores an active read further up interrupts the person reading older messages. Check the scroll position before each update and jump to the newest message only when the user has not scrolled away.

Self-Check

Predict the output from this room-access helper:

<?php

function accessLabel(bool $signedIn, bool $roomMember): string
{
    return $signedIn && $roomMember ? "allowed" : "denied";
}

echo accessLabel(true, true);
  1. Predict the output: Which word does the snippet print?
  2. Multiple choice: Which browser API inserts message text safely: textContent or innerHTML?
  3. Predict the output: Which word prints for accessLabel(true, false)?
  4. Multiple choice: Which request should create a message: GET or POST with a valid CSRF token?
  5. Multiple choice: Which first transport is simplest for a low-volume room: three-second polling or a WebSocket service?

Answers

  1. allowed. Both conditions are true, so the conditional expression selects the first label.
  2. textContent. It inserts the message as text instead of parsing it as HTML.
  3. denied. A signed-in user still needs membership in the requested room.
  4. POST with a valid CSRF token. Creating a message changes state and needs the protected write boundary.
  5. Three-second polling. It uses ordinary requests and keeps the first version easy to inspect and throttle.

Next Steps

Read PHP and MySQL with PDO for the database boundary, then read SQL indexes and query optimization before the message table becomes the slowest part of the page.

Sources

  1. [1]
    PDO::prepare
    (php.net)
  2. [2]
  3. [3]
  4. [4]
  5. [5]
  6. [6]
  7. [7]
    How MySQL Uses Indexes
    (dev.mysql.com)