Build a Mini Chat App with PHP

Published Updated

A small chat app combines PHP, MySQL, and JavaScript. PHP checks who can use each room and saves messages. JavaScript fetches new messages without reloading the page.

This tutorial builds on an existing login system and PDO connection. You will need PHP with pdo_mysql and mbstring, a MySQL database, and a users table with an unsigned BIGINT id and a display_name. The examples show the chat handlers, not a complete registration or login system.

Guide Path

Read this after building a small PHP database app and before using sockets in PHP. The chat app combines polling, login sessions, CSRF protection, and database queries.

What Does a PHP Chat App Need?

A PHP chat app needs a logged-in user, room membership checks, message storage, and endpoints for sending and reading messages.

  • 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.

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_id_idx (room_id, 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.

The index starts with room_id, then id, matching the query below. It helps MySQL find newer messages in one room without scanning every room. See how SQL indexes support queries for the reasoning.

Insert Messages with PDO

The write path should validate the body before SQL and use prepared statements for values. PDO placeholders carry values, which is all 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 to 1000 characters or fewer.");
    }

    $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(mixed $token): void
{
    $sessionToken = $_SESSION["csrf_token"] ?? null;

    if (
        ! is_string($sessionToken)
        || ! is_string($token)
        || ! 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);

    $body = $_POST["body"] ?? "";
    if (! is_string($body)) {
        http_response_code(400);
        exit("Message must be text.");
    }
    createMessage($pdo, $roomId, $userId, $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.

Start the session in both request handlers before reading the logged-in user. Load your existing db() and requireUserId() helpers before these handlers. The PHP PDO guide covers the connection; PHP authentication and sessions covers the logged-in user. Define currentRoomId() to validate the room ID from your route and reject missing or invalid IDs. Both handlers must resolve the same room. Include csrfToken() in the posting form as a hidden field.

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");
$afterId = filter_var($_GET["after_id"] ?? "0", FILTER_VALIDATE_INT, [
    "options" => ["min_range" => 0],
]);
if ($afterId === false) {
    http_response_code(400);
    exit(json_encode(["error" => "Invalid message ID."]));
}
echo json_encode([
    "messages" => messagesSince($pdo, $roomId, $afterId),
]);

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) {
    if (document.querySelector('[data-message-id="' + message.id + '"]')) continue;
    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;
  }
}

async function pollMessages() {
  try {
    await refreshMessages();
  } catch (error) {
    console.error("Could not load new messages.", error);
  } finally {
    setTimeout(pollMessages, 3000);
  }
}
pollMessages();

The loop waits for each request to finish before scheduling the next, so slow requests cannot overlap. Adapt the URL to include your validated room route. This teaching example uses numeric IDs within JavaScript’s safe integer range; larger IDs need string handling. For a busy chat, also handle messages committed out of ID order, rate limits, and reconnecting clients before deployment.

Should a PHP Chat App Use Polling, SSE, or WebSockets?

A small PHP chat app can start with polling. SSE delivers server updates over a persistent connection; WebSockets support messages in both directions over one connection.

Use polling when the chat is low volume, the app is small, and you want ordinary PHP request handling. A three-second interval 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 polling display duplicate messages in a PHP chat app?

Polling can display duplicate chat messages when requests overlap or the client inserts the same message ID twice. Two separate tabs do not duplicate each other’s page content. Wait for each request to finish and check message IDs before adding them.

Should PHP chat polling slow down in an idle tab?

PHP chat polling can slow down when no messages arrive and pause while the tab is hidden. Increasing the delay reduces unnecessary requests. This example keeps a fixed delay between completed requests to make the basic loop easier to follow.

Should PHP chat messages use separate database rows?

Store each PHP chat message in its own database row. Separate rows make pagination, sender filters, and message inserts straightforward. MySQL can query and index JSON data, but one growing JSON conversation complicates these routine operations.

Can chat messages contain Markdown?

Chat messages can contain Markdown if the app parses it and sanitizes the resulting HTML before display. Plain text is simpler to handle safely. Assigning untrusted HTML to innerHTML can allow malicious markup to run.

Should a chat message list auto-scroll when new messages arrive?

A chat message list should auto-scroll only when the reader is already at the bottom. Keep the scroll position unchanged while someone reads older messages, and show a new-message indicator instead.

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. Check the user and room membership before saving the message.
  5. Three-second polling. It uses ordinary requests and keeps the first version easy to inspect and throttle.

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)