How to Make a Blog Website in PHP with an Admin Panel (No WordPress)
Build your own blog in PHP without WordPress: a complete one-file blog with a password-protected admin page you can upload today, how to switch it to MySQL, the security it needs, and what a full blog script adds.
You do not need WordPress to run a blog. A blog is a list of posts, a page for each post, and a private page where you write new ones. That fits in a single PHP file, and building it yourself teaches you how every blog works underneath, WordPress included.
This guide gives you a complete one-file PHP blog with an admin page that you can upload today, then shows how to switch it to MySQL, the security every PHP blog needs, and what a full blog script adds for a site you want to grow.
Try it: a blog in one PHP file
- Save the code below as
index.php. - Change
ADMIN_PASSWORDto your own long password. - On your computer, put it in the web folder of XAMPP or Laragon (for example
htdocs/blog/) and openhttp://localhost/blog/. On hosting, upload it topublic_html. - Open
?admin(for exampleyourdomain.com/?admin), log in, and publish your first post.
It uses SQLite, a database stored in a single file, so there is nothing to set up: the file blog.sqlite is created on the first visit, one folder above the website, where nobody can download it.
<?php
/* A one-file blog with an admin page. Save it as index.php. Needs PHP 8 with SQLite (pdo_sqlite). */
const BLOG_NAME = 'My Blog';
const ADMIN_PASSWORD = 'change-this-password'; // change it before you upload
session_start();
// The database file sits one folder above the website, where nobody can download it
$db = new PDO('sqlite:' . dirname(__DIR__) . '/blog.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
body TEXT NOT NULL,
created TEXT NOT NULL
)');
function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16));
$message = '';
// Admin: log in, log out, publish
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) exit('Please reload the page and try again.');
$action = $_POST['action'] ?? '';
if ($action === 'login' && hash_equals(ADMIN_PASSWORD, $_POST['password'] ?? '')) {
session_regenerate_id(true);
$_SESSION['admin'] = true;
} elseif ($action === 'login') {
sleep(1); // slows down anyone guessing passwords
$message = 'Wrong password.';
} elseif ($action === 'logout') {
$_SESSION = [];
} elseif ($action === 'publish' && !empty($_SESSION['admin'])) {
$title = trim($_POST['title'] ?? '');
$body = trim($_POST['body'] ?? '');
$slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($title)), '-') ?: 'post';
if ($title !== '' && $body !== '') {
$taken = $db->prepare('SELECT COUNT(*) FROM posts WHERE slug = ?');
$taken->execute([$slug]);
if ($taken->fetchColumn()) $slug .= '-' . time();
$db->prepare('INSERT INTO posts (title, slug, body, created) VALUES (?, ?, ?, ?)')
->execute([$title, $slug, $body, date('Y-m-d H:i')]);
header('Location: ?post=' . urlencode($slug));
exit;
}
$message = 'A title and some text are both needed.';
}
}
$post = null;
if (isset($_GET['post'])) {
$find = $db->prepare('SELECT * FROM posts WHERE slug = ?');
$find->execute([$_GET['post']]);
$post = $find->fetch(PDO::FETCH_ASSOC);
if (!$post) http_response_code(404);
}
$adminPage = isset($_GET['admin']);
$pageTitle = $post ? $post['title'] . ' | ' . BLOG_NAME : BLOG_NAME;
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle) ?></title>
<?php if ($post): ?><meta name="description" content="<?= e(mb_substr(preg_replace('/\s+/', ' ', $post['body']), 0, 155)) ?>"><?php endif; ?>
<?php if ($adminPage): ?><meta name="robots" content="noindex"><?php endif; ?>
<style>
body { margin: 0; font-family: system-ui, sans-serif; background: #fafaf9; color: #1c1917; }
header { padding: 16px 20px; background: #1c1917; }
header a { color: #fff; font-size: 20px; font-weight: 800; text-decoration: none; }
main { max-width: 720px; margin: 0 auto; padding: 24px 20px; line-height: 1.6; }
article { margin-bottom: 28px; }
.date { color: #78716c; font-size: 14px; }
input, textarea { width: 100%; box-sizing: border-box; font: inherit; padding: 10px; margin: 6px 0 12px;
border: 1px solid #d6d3d1; border-radius: 8px; }
textarea { min-height: 220px; }
button { font: inherit; font-weight: 700; padding: 10px 20px; border: 0; border-radius: 8px;
background: #ea580c; color: #fff; cursor: pointer; }
.msg { color: #b91c1c; }
</style>
</head>
<body>
<header><a href="?"><?= e(BLOG_NAME) ?></a></header>
<main>
<?php if ($message): ?><p class="msg"><?= e($message) ?></p><?php endif; ?>
<?php if ($adminPage && empty($_SESSION['admin'])): ?>
<h1>Log in</h1>
<form method="post">
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
<input type="hidden" name="action" value="login">
<input type="password" name="password" placeholder="Admin password" required>
<button>Log in</button>
</form>
<?php elseif ($adminPage): ?>
<h1>New post</h1>
<form method="post">
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
<input type="hidden" name="action" value="publish">
<input name="title" placeholder="Title" required>
<textarea name="body" placeholder="Write your post. A blank line starts a new paragraph." required></textarea>
<button>Publish</button>
</form>
<form method="post" style="margin-top: 16px">
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
<input type="hidden" name="action" value="logout">
<button style="background: #57534e">Log out</button>
</form>
<?php elseif ($post): ?>
<article>
<h1><?= e($post['title']) ?></h1>
<div class="date"><?= e($post['created']) ?></div>
<?php foreach (preg_split('/\R{2,}/', $post['body']) as $paragraph): ?>
<p><?= nl2br(e($paragraph)) ?></p>
<?php endforeach; ?>
</article>
<a href="?">← All posts</a>
<?php elseif (isset($_GET['post'])): ?>
<h1>Post not found</h1>
<a href="?">← All posts</a>
<?php else: ?>
<?php $posts = $db->query('SELECT title, slug, body, created FROM posts ORDER BY id DESC')->fetchAll(PDO::FETCH_ASSOC); ?>
<?php if (!$posts): ?><p>No posts yet. <a href="?admin">Write the first one</a>.</p><?php endif; ?>
<?php foreach ($posts as $p): ?>
<article>
<h2><a href="?post=<?= e(urlencode($p['slug'])) ?>"><?= e($p['title']) ?></a></h2>
<div class="date"><?= e($p['created']) ?></div>
<p><?= e(mb_substr($p['body'], 0, 200)) ?>…</p>
</article>
<?php endforeach; ?>
<?php endif; ?>
</main>
</body>
</html>
| Part | What it does |
|---|---|
new PDO('sqlite:...') | Opens (or creates) the database file outside the public folder |
CREATE TABLE IF NOT EXISTS | Makes the posts table the first time; after that it does nothing |
e() with htmlspecialchars | Every piece of text is escaped before it is shown, so a title like <script> appears as text and never runs |
prepare() and execute() | Prepared statements: what users type can never change your SQL |
The csrf token | Every form carries a secret from the session, so another website cannot post to your admin |
session_regenerate_id and sleep(1) | A fresh session after login, and a pause after a wrong password that slows down guessing |
| The slug | Turns “My First Post” into my-first-post for the address, and adds a number if it is already taken |
http_response_code(404) | A missing post returns a real 404, which Google needs |
Use MySQL instead of SQLite
SQLite is fine for a personal blog. If your hosting gives you MySQL, or you expect many writers, change the database lines like this and keep the rest of the file:
// Instead of the SQLite line, connect to MySQL (create the database in cPanel first):
$db = new PDO('mysql:host=localhost;dbname=myblog;charset=utf8mb4', 'db_user', 'db_password');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
slug VARCHAR(220) NOT NULL UNIQUE,
body MEDIUMTEXT NOT NULL,
created VARCHAR(20) NOT NULL
)');
Store a password hash, not the password
The one-file blog keeps the admin password in the file to stay short. For a real site, store only a hash of it, so even someone who reads the file does not learn the password:
// Make a hash once (for example in a temporary file), then delete that file:
echo password_hash('your-long-password', PASSWORD_DEFAULT);
// In the blog, keep only the hash and check against it:
const ADMIN_HASH = '$2y$10$...the long hash...';
if ($action === 'login' && password_verify($_POST['password'] ?? '', ADMIN_HASH)) { ... }
What a real blog adds
The one-file blog works, but a blog you want to grow in Google needs more:
- Clean addresses like
/my-first-post/instead of?post=, with.htaccessrewrite rules - Editing and deleting posts, drafts, and a preview before publishing
- Categories, a search box and a sidebar or menu
- Cover pictures, safely uploaded and converted to WebP
- SEO: a title and description per post, canonical links, a sitemap, Article and FAQ structured data
- Pages: About, Contact (with spam protection) and Privacy Policy, which AdSense needs
- Stronger security: hashed passwords, locking a login after several wrong passwords, safe uploads and hidden error messages
- An installer, so the site can be set up without editing code
Your own PHP blog or WordPress?
| Own PHP blog | WordPress | |
|---|---|---|
| Speed | Very fast: only the code you need | Slower without caching plugins |
| Updates | Only when you change something | Core, theme and plugin updates every few weeks |
| Security | Small code, small attack surface | The most attacked CMS; plugins are the usual way in |
| Features | What you build or buy | Thousands of plugins |
| Best for | Learning, speed, a simple focused blog | Sites that need many ready-made features |
Common problems
| Problem | Cause and fix |
|---|---|
could not find driver | The SQLite (or MySQL) extension is off. Turn on pdo_sqlite or pdo_mysql in your hosting's PHP settings. |
unable to open database file | PHP cannot write in the folder above the website. Use __DIR__ instead of dirname(__DIR__) and protect that file from downloads, or use MySQL. |
headers already sent | Something is printed before <?php, often a space or a blank line at the top of the file. Remove it. |
| “Please reload the page” after a long wait | The session expired, so the form's CSRF token no longer matches. Reload and try again. |
| Bold or links show as code in posts | On purpose: everything is escaped for safety. A real blog uses an editor that saves cleaned HTML. |
The full version: a ready blog CMS
Our PHP blog script with admin panel is a fast blog with no WordPress and no plugins. It is the same kind of blog you are reading now:
- Articles with a cover picture, summary, level badge, reading time and a table of contents made from the headings
- An FAQ section on every article, sent to Google as FAQ structured data
- Categories with sub-categories and a drop-down menu, live search, and editable About, Contact, Privacy, Terms and Disclaimer pages
- Draft preview links, and automatic 301 redirects when you change an address
- Logo, favicon, colours (6 presets or your own) and home page texts from the admin
- SEO and AI search: canonical links, Open Graph, structured data, a sitemap and
llms.txt - AdSense with
ads.txtand three ad places that follow the placement rules - Security: 4 wrong admin passwords lock the login for 15 minutes, CSRF protection, prepared statements, safe WebP uploads and a spam-protected contact form
Where to go next
- Install Apache, MySQL and PHP on Ubuntu to run it on your own server
- What actually matters in cheap web hosting
- Get your blog ranking in Google